cloud-web-corejs 1.0.281 → 1.1.0-dev.4

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 (158) hide show
  1. package/package.json +6 -2
  2. package/src/components/VabUpload/image-viewer.vue +442 -442
  3. package/src/components/VabUpload/index.vue +44 -2
  4. package/src/components/VabUpload/mixins.js +126 -19
  5. package/src/components/VabUpload/view.vue +247 -209
  6. package/src/components/baseTabs/mixins.js +181 -181
  7. package/src/components/bizTab/BizBillActions.vue +129 -0
  8. package/src/components/bizTab/BizTabListPage.vue +391 -0
  9. package/src/components/bizTab/README.md +378 -0
  10. package/src/components/bizTab/billTableUtil.js +48 -0
  11. package/src/components/bizTab/billValidators.js +136 -0
  12. package/src/components/bizTab/bizBillDetailMixin.js +705 -0
  13. package/src/components/bizTab/index.js +24 -0
  14. package/src/components/excelExport/exportFieldDialog.vue +23 -12
  15. package/src/components/excelExport/exportItemConfigUtil.js +30 -0
  16. package/src/components/excelExport/mixins.js +3 -3
  17. package/src/components/mobile/statusTag/mixins.js +1 -61
  18. package/src/components/mobile/wf/content.vue +1662 -1556
  19. package/src/components/mobile/wf/mixins/wfModifyDialogMixin.js +8 -6
  20. package/src/components/table/config.js +78 -78
  21. package/src/components/table/index.js +1276 -1276
  22. package/src/components/table/plugins/cell-area/index.js +1055 -0
  23. package/src/components/table/plugins/cell-area/index.scss +77 -0
  24. package/src/components/table/plugins/cell-area/util.js +224 -0
  25. package/src/components/table/tableForm.vue +330 -330
  26. package/src/components/table/tableFormMixin.js +318 -318
  27. package/src/components/table/vxeFilter/index.js +163 -163
  28. package/src/components/table/vxeFilter/mixin.js +316 -316
  29. package/src/components/wf/content.vue +1188 -1188
  30. package/src/components/wf/wf.js +40 -20
  31. package/src/components/wf/wfActionDropdown.vue +1 -1
  32. package/src/components/wf/wfActionItems.js +1 -1
  33. package/src/components/xform/docs/2026-08 xform H5 P0 /345/256/236/347/216/260/350/247/204/346/240/274.md" +528 -0
  34. package/src/components/xform/docs/2026-08 xform H5 P1 /345/256/236/347/216/260/350/247/204/346/240/274.md" +412 -0
  35. package/src/components/xform/docs/2026-08 xform H5 P2 /345/256/236/347/216/260/350/247/204/346/240/274.md" +287 -0
  36. package/src/components/xform/docs/2026-08 xform /347/247/273/345/212/250/347/253/257/345/270/203/345/261/200/345/257/271/351/275/220 bill_setting /346/226/271/346/241/210.md" +400 -0
  37. package/src/components/xform/form-designer/designer.js +15 -0
  38. package/src/components/xform/form-designer/form-widget/container-widget/data-table-widget.vue +269 -269
  39. package/src/components/xform/form-designer/form-widget/container-widget/detail-temp-widget.vue +132 -0
  40. package/src/components/xform/form-designer/form-widget/container-widget/detail-widget.vue +250 -250
  41. package/src/components/xform/form-designer/form-widget/dialog/fileReferenceDialog.vue +319 -320
  42. package/src/components/xform/form-designer/form-widget/dialog/formDialog.vue +365 -365
  43. package/src/components/xform/form-designer/form-widget/dialog/searchFormDialog.vue +554 -555
  44. package/src/components/xform/form-designer/form-widget/dialog/vabSearchDialog.vue +409 -409
  45. package/src/components/xform/form-designer/form-widget/field-widget/a-link-widget.vue +150 -132
  46. package/src/components/xform/form-designer/form-widget/field-widget/date-range-widget.vue +114 -114
  47. package/src/components/xform/form-designer/form-widget/field-widget/date-widget.vue +154 -154
  48. package/src/components/xform/form-designer/form-widget/field-widget/form-item-wrapper.vue +47 -1
  49. package/src/components/xform/form-designer/form-widget/field-widget/mixins/date-limit-mixin.js +178 -178
  50. package/src/components/xform/form-designer/form-widget/field-widget/mixins/time-limit-mixin.js +77 -77
  51. package/src/components/xform/form-designer/form-widget/field-widget/select-export-item-button-widget.vue +6 -0
  52. package/src/components/xform/form-designer/form-widget/field-widget/select-widget.vue +139 -139
  53. package/src/components/xform/form-designer/form-widget/field-widget/time-range-widget.vue +106 -106
  54. package/src/components/xform/form-designer/form-widget/field-widget/time-widget.vue +134 -134
  55. package/src/components/xform/form-designer/form-widget/field-widget/vabUpload-widget.vue +1 -0
  56. package/src/components/xform/form-designer/setting-panel/form-setting.vue +39 -39
  57. package/src/components/xform/form-designer/setting-panel/property-editor/container-data-table/data-table-editor.vue +10 -10
  58. package/src/components/xform/form-designer/setting-panel/property-editor/container-data-table/exportItemColumns-dialog.vue +438 -432
  59. package/src/components/xform/form-designer/setting-panel/property-editor/container-data-table/table-column-dialog.vue +2103 -2103
  60. package/src/components/xform/form-designer/setting-panel/property-editor/container-detail/detail-temp-edito.vue +90 -0
  61. package/src/components/xform/form-designer/setting-panel/property-editor/container-grid/gutter-editor.vue +82 -82
  62. package/src/components/xform/form-designer/setting-panel/property-editor/container-list-h5/list-h5-editor.vue +19 -5
  63. package/src/components/xform/form-designer/setting-panel/property-editor/container-table/table-dynamicField-editor.vue +8 -8
  64. package/src/components/xform/form-designer/setting-panel/property-editor/dropdownFlag-editor.vue +137 -137
  65. package/src/components/xform/form-designer/setting-panel/property-editor/field-button/search-dialog-event-editor.vue +237 -237
  66. package/src/components/xform/form-designer/setting-panel/property-editor/field-date/date-defaultValue-editor.vue +43 -43
  67. package/src/components/xform/form-designer/setting-panel/property-editor/field-date-range/date-range-defaultValue-editor.vue +43 -43
  68. package/src/components/xform/form-designer/setting-panel/property-editor/field-import-button/import2-button-editor.vue +96 -96
  69. package/src/components/xform/form-designer/setting-panel/property-editor/field-table-export-button/select-export-item-button-editor.vue +51 -0
  70. package/src/components/xform/form-designer/setting-panel/property-editor/field-time/time-defaultValue-editor.vue +42 -42
  71. package/src/components/xform/form-designer/setting-panel/property-editor/field-time-range/time-range-defaultValue-editor.vue +43 -43
  72. package/src/components/xform/form-designer/setting-panel/property-editor/formScriptEnabled-editor.vue +158 -158
  73. package/src/components/xform/form-designer/setting-panel/property-editor/h5Span-editor.vue +51 -0
  74. package/src/components/xform/form-designer/setting-panel/property-editor/itemStyle-editor.vue +39 -0
  75. package/src/components/xform/form-designer/setting-panel/property-editor/name-editor.vue +188 -188
  76. package/src/components/xform/form-designer/setting-panel/property-editor/styleTempClass-editor.vue +51 -0
  77. package/src/components/xform/form-designer/setting-panel/property-editor/textFlag-editor.vue +347 -347
  78. package/src/components/xform/form-designer/setting-panel/propertyRegister.js +4 -0
  79. package/src/components/xform/form-designer/widget-panel/widgetsConfig.js +59 -3
  80. package/src/components/xform/form-render/container-item/data-table-mixin.js +151 -93
  81. package/src/components/xform/form-render/container-item/detail-h5-item.vue +183 -32
  82. package/src/components/xform/form-render/container-item/detail-temp-item.vue +110 -0
  83. package/src/components/xform/form-render/container-item/h5-card-pane-item.vue +160 -140
  84. package/src/components/xform/form-render/container-item/list-h5-item.vue +1688 -1700
  85. package/src/components/xform/form-render/index.vue +153 -147
  86. package/src/components/xform/form-render/indexMixin.js +12 -0
  87. package/src/components/xform/lang/en-US.js +10 -0
  88. package/src/components/xform/lang/zh-CN.js +12 -0
  89. package/src/components/xform/mixins/defaultHandle.js +707 -707
  90. package/src/components/xform/styles/h5.scss +1992 -0
  91. package/src/components/xform/utils/textMaskUtil.js +182 -179
  92. package/src/components/xform/utils/treeTableUtil.js +1292 -1265
  93. package/src/components/xform/utils/util.js +56 -11
  94. package/src/index.js +9 -4
  95. package/src/layout/components/extractedCode/viewDialog.vue +7 -7
  96. package/src/router/index.js +21 -0
  97. package/src/router/modules/customer.js +71 -14
  98. package/src/store/modules/permission.js +17 -18
  99. package/src/utils/pdfUtil.js +429 -429
  100. package/src/utils/resolveViewComponent.js +142 -16
  101. package/src/views/bd/project/branch/bd_branch/edit.vue +145 -0
  102. package/src/views/bd/project/branch/bd_branch/list.vue +72 -0
  103. package/src/views/bd/project/branch/bd_branch/menuKindDialog.vue +106 -0
  104. package/src/views/bd/project/branch/bd_branch/mixins/edit.js +189 -0
  105. package/src/views/bd/project/branch/bd_branch/mixins/list.js +124 -0
  106. package/src/views/bd/project/branch/bd_branch/mixins/menuKindDialog.js +85 -0
  107. package/src/views/bd/project/branch/form_script/list.vue +31 -0
  108. package/src/views/bd/project/branch/form_template/list.vue +30 -0
  109. package/src/views/bd/project/branch/menu_kind/list.vue +31 -0
  110. package/src/views/bd/project/common/form_script/list.vue +16 -0
  111. package/src/views/bd/project/common/form_template/list.vue +15 -0
  112. package/src/views/bd/project/common/menu_kind/list.vue +15 -0
  113. package/src/views/bd/project/core/branchContext.js +100 -0
  114. package/src/views/bd/project/core/branchPage.js +78 -0
  115. package/src/views/bd/project/core/branchPageWrap.vue +225 -0
  116. package/src/views/bd/project/core/branchScope.js +44 -0
  117. package/src/views/bd/project/core/branchSearchPage.js +285 -0
  118. package/src/views/bd/project/core/branchSelect.vue +85 -0
  119. package/src/views/bd/project/core/branchTreeBar.vue +81 -0
  120. package/src/views/bd/project/core/httpBranchPatch.js +58 -0
  121. package/src/views/bd/project/datatable/menu_kind/list.vue +15 -0
  122. package/src/views/bd/project/datatable/table_model/list.vue +15 -0
  123. package/src/views/bd/setting/bd_company_env/dialog.vue +174 -174
  124. package/src/views/bd/setting/table_model/edit.vue +1066 -1066
  125. package/src/views/bd/setting/table_model/mixins/edit.js +1455 -1455
  126. package/src/views/bd/setting/table_model/mixins/zdDialog.js +148 -148
  127. package/src/views/demo/bizTab/bill/billConfig.js +41 -0
  128. package/src/views/demo/bizTab/bill/detail.vue +436 -0
  129. package/src/views/demo/bizTab/bill/list.vue +288 -0
  130. package/src/views/demo/bizTab/bill/productDialog.vue +176 -0
  131. package/src/views/demo/bizTab/simple/billApi.js +123 -0
  132. package/src/views/demo/bizTab/simple/detail.vue +407 -0
  133. package/src/views/demo/bizTab/simple/list.vue +217 -0
  134. package/src/views/demo/bizTab/standalone/index.vue +121 -0
  135. package/src/views/demo/bizTab/wf/billConfig.js +44 -0
  136. package/src/views/demo/bizTab/wf/detail.vue +661 -0
  137. package/src/views/demo/bizTab/wf/list.vue +242 -0
  138. package/src/views/support/form-dialog-demo/demoPickerDialog.vue +72 -0
  139. package/src/views/support/form-dialog-demo/index.vue +537 -0
  140. package/src/views/user/file_view_ins/list.vue +3 -3
  141. package/src/views/user/home/index.vue +8 -6
  142. package/src/views/user/home/wjl/content.vue +1569 -0
  143. package/src/views/user/notify_message/dialog.vue +7 -5
  144. package/src/views/user/outLink/cc_form_view.vue +188 -0
  145. package/src/views/user/outLink/form_view.vue +2 -2
  146. package/src/views/user/outLink/index.vue +4 -4
  147. package/src/views/user/outLink/view.vue +3 -3
  148. package/src/views/user/role/edit.vue +632 -632
  149. package/src/views/user/role/list.vue +260 -260
  150. package/src/views/user/wf/iframe/index.vue +3 -3
  151. package/src/views/user/wf/iframe/index2.vue +3 -3
  152. package/src/views/user/wf/wf_manage/list.vue +1112 -1116
  153. package/src/views/user/wf/wf_manage/list2.vue +9 -13
  154. package/src/views/user/wf/wf_manage/wfContentDialog.vue +8 -5
  155. package/src/components/xform/form-designer/form-widget/field-widget/dropdown-menu-widget.vue +0 -106
  156. package/src/components/xform/form-designer/form-widget/field-widget/tableexportbuttonwidget.vue +0 -99
  157. package/src/components/xform/form-render/container-item/list-h5-item2.vue +0 -1340
  158. package/src/utils/wf.js +0 -766
@@ -1,429 +1,429 @@
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
- //Safari(WebKit) 的 getComputedStyle 会把颜色序列化成 color(srgb …) / color(display-p3 …),
110
- //而 html2canvas 1.4.1 只认 rgb/rgba/hsl/hsla 四种,撞上就抛
111
- //"Attempting to parse an unsupported color function" 直接整个导出失败。
112
- //项目源码里并没有写过 color(),是浏览器自己吐出来的,改样式没用——只能在克隆体上就地换算。
113
- const COLOR_FUNCTION = /color\([^()]*\)/g;
114
-
115
- //html2canvas 会按颜色解析的属性(format: 'color'),外加三个值里可能夹着颜色的:
116
- //渐变、阴影里的 color() 一样会把解析器打挂。
117
- const COLOR_PROPS = [
118
- "color",
119
- "background-color",
120
- "border-top-color",
121
- "border-right-color",
122
- "border-bottom-color",
123
- "border-left-color",
124
- "text-decoration-color",
125
- "-webkit-text-stroke-color",
126
- "background-image",
127
- "box-shadow",
128
- "text-shadow",
129
- ];
130
-
131
- //探针色:用来判断 canvas 到底认没认这个值(不认的话 fillStyle 会保持原样)
132
- const PROBE_COLOR = "#010203";
133
-
134
- function isWebKit() {
135
- const ua = typeof navigator === "undefined" ? "" : navigator.userAgent || "";
136
- //Chrome 系的 UA 里也有 AppleWebKit 字样,但它不会这么序列化,得排掉
137
- return /AppleWebKit/.test(ua) && !/Chrome|Chromium|Edg\//.test(ua);
138
- }
139
-
140
- function probeContext() {
141
- try {
142
- const canvas = document.createElement("canvas");
143
- return typeof canvas.getContext === "function" ? canvas.getContext("2d") : null;
144
- } catch (err) {
145
- return null;
146
- }
147
- }
148
-
149
- function toByte(raw) {
150
- const num = parseFloat(raw);
151
- if (isNaN(num)) return 0;
152
- const ratio = raw.indexOf("%") === -1 ? num : num / 100;
153
- return Math.max(0, Math.min(255, Math.round(ratio * 255)));
154
- }
155
-
156
- function toAlpha(raw) {
157
- const num = parseFloat(raw);
158
- if (isNaN(num)) return 1;
159
- const alpha = raw.indexOf("%") === -1 ? num : num / 100;
160
- return Math.max(0, Math.min(1, alpha));
161
- }
162
-
163
- //浏览器不肯换算时的兜底:按 sRGB 直接取分量。
164
- //display-p3 这样算是近似(色域没做映射),但截图里看不出差别,总好过整个导出失败。
165
- function fallbackColor(value) {
166
- const inside = value.slice(value.indexOf("(") + 1, value.lastIndexOf(")"));
167
- const parts = inside.split("/");
168
- //第一段是色彩空间名(srgb / display-p3 …),分量从第二段开始
169
- const numbers = parts[0].trim().split(/\s+/).slice(1);
170
- const channels = [0, 1, 2].map((i) => toByte(numbers[i] || "0"));
171
- const alpha = parts.length > 1 ? toAlpha(parts[1]) : 1;
172
- return "rgba(" + channels.join(", ") + ", " + alpha + ")";
173
- }
174
-
175
- function toLegacyColor(value, ctx) {
176
- return value.replace(COLOR_FUNCTION, (match) => {
177
- //优先让浏览器自己换:它认识 color(),换出来的 sRGB 值是准的
178
- if (ctx) {
179
- ctx.fillStyle = PROBE_COLOR;
180
- ctx.fillStyle = match;
181
- const converted = ctx.fillStyle;
182
- const usable = typeof converted === "string" && converted.indexOf("color(") === -1;
183
- if (usable && converted !== PROBE_COLOR) return converted;
184
- }
185
- return fallbackColor(match);
186
- });
187
- }
188
-
189
- //在 html2canvas 的克隆体上把 color() 全部换成 rgba(),写成内联样式(优先级最高)。
190
- //只在需要时才挂上去:这一趟要给每个节点再算一次样式,白跑一遍就是白卡一下。
191
- export function normalizeColorFunctions(clonedDoc, clonedElement) {
192
- const view = clonedDoc.defaultView;
193
- if (!view || typeof view.getComputedStyle !== "function") return;
194
- const ctx = probeContext();
195
- const targets = [];
196
- //除了目标元素,html2canvas 还会读 html/body 的背景色(parseBackgroundColor),一起带上
197
- if (clonedDoc.documentElement) targets.push(clonedDoc.documentElement);
198
- if (clonedDoc.body) targets.push(clonedDoc.body);
199
- targets.push(clonedElement);
200
- const children = clonedElement.querySelectorAll("*");
201
- for (let i = 0; i < children.length; i++) targets.push(children[i]);
202
-
203
- for (let i = 0; i < targets.length; i++) {
204
- const el = targets[i];
205
- if (!el || !el.style) continue;
206
- const style = view.getComputedStyle(el);
207
- for (let j = 0; j < COLOR_PROPS.length; j++) {
208
- const prop = COLOR_PROPS[j];
209
- const value = style.getPropertyValue(prop);
210
- if (!value || value.indexOf("color(") === -1) continue;
211
- el.style.setProperty(prop, toLegacyColor(value, ctx));
212
- }
213
- }
214
- }
215
-
216
- function isUnsupportedColorError(err) {
217
- const message = err && err.message ? err.message : String(err || "");
218
- return message.indexOf("unsupported color function") !== -1;
219
- }
220
-
221
- function withColorFix(options) {
222
- return Object.assign({}, options, { onclone: normalizeColorFunctions });
223
- }
224
-
225
- async function renderCanvas(dom, options, fixColors) {
226
- try {
227
- return await html2canvas(dom, fixColors ? withColorFix(options) : options);
228
- } catch (err) {
229
- //别的引擎哪天也开始这么序列化,兜一次;已经带着换算跑过就别再重来了
230
- if (fixColors || !isUnsupportedColorError(err)) throw err;
231
- return html2canvas(dom, withColorFix(options));
232
- }
233
- }
234
-
235
- function buildIgnore(selectors) {
236
- let list = [LOADING_MASK];
237
- if (selectors) list = list.concat(selectors);
238
- const selector = list.join(",");
239
- return function (el) {
240
- return typeof el.matches === "function" && el.matches(selector);
241
- };
242
- }
243
-
244
- //手写一层 element 样式的遮罩,而不是用 Loading.service,两个原因都躲不掉:
245
- //1. `Loading.service({ fullscreen: true })` 是全局单例,会被 request.js 里的请求收尾顺手关掉,
246
- // 而导出这几秒里页面上别的请求随时可能回来;
247
- //2. `Loading.service({ target: dom })` 会给 dom 加 el-loading-parent--relative(position:relative !important),
248
- // 页面里 .slide-nav 这类绝对定位元素的包含块跟着变,位置会挪——而且是连截进 PDF 里的。
249
- //自己建的 DOM 节点两头都不沾,也不用担心被别处关掉。
250
- //挂载点取根元素的父级,不挂 body:微前端下 body 是主应用的,往上加节点算越界;
251
- //挂父级也让这个节点跟着页面走,页面被销毁时不会剩一层孤儿遮罩。
252
- //只遮导出的那块区域,不铺满视口:左侧菜单、页签这些跟导出无关的地方不该被压住。
253
- //做法是 fixed + 按导出元素的 getBoundingClientRect 圈定范围(取展开之前的可视位置),
254
- //这样既不用给挂载点加 position:relative(会挪走 .slide-nav 这类绝对定位元素,
255
- //也正是不能用 Loading.service({ target }) 的原因),也不会盖到别人头上。
256
- //为什么不挂根元素自己:加子节点会改变根元素下 :last-child 的匹配,
257
- //而那种样式差异是会被截进 PDF 的;挂在父级上则完全在截图范围之外。
258
- function openLoading(dom, option) {
259
- if (option.loading === false) return null;
260
- const host = dom.parentElement || dom;
261
- //必须在 expandForCapture 之前量:展开之后元素会被撑到完整内容高度,量出来是没用的
262
- const rect = dom.getBoundingClientRect();
263
- const top = Math.max(rect.top, 0);
264
- const left = Math.max(rect.left, 0);
265
- const height = Math.min(rect.bottom, window.innerHeight) - top;
266
- const width = Math.min(rect.right, window.innerWidth) - left;
267
- const mask = document.createElement("div");
268
- mask.className = "el-loading-mask";
269
- //.el-loading-mask 自带 top/right/bottom/left:0,right/bottom 要显式清掉,
270
- //否则宽高会被拉回视口边缘,又变成全屏
271
- mask.style.position = "fixed";
272
- mask.style.top = top + "px";
273
- mask.style.left = left + "px";
274
- mask.style.right = "auto";
275
- mask.style.bottom = "auto";
276
- mask.style.width = width + "px";
277
- mask.style.height = height + "px";
278
- mask.innerHTML = [
279
- '<div class="el-loading-spinner">',
280
- '<svg class="circular" viewBox="25 25 50 50">',
281
- '<circle class="path" cx="50" cy="50" r="20" fill="none"></circle>',
282
- "</svg>",
283
- '<p class="el-loading-text"></p>',
284
- "</div>",
285
- ].join("");
286
- const label = mask.querySelector(".el-loading-text");
287
- label.textContent = option.loadingText || "正在生成PDF,请稍候…";
288
- host.appendChild(mask);
289
- return {
290
- //分阶段换文案:截图那段主线程是整块占住的,转圈会定住,
291
- //有个往前走的文案才看得出是在干活而不是卡死。调用方自带文案时不覆盖。
292
- setText(next) {
293
- if (!option.loadingText) label.textContent = next;
294
- },
295
- close() {
296
- if (mask.parentNode) mask.parentNode.removeChild(mask);
297
- },
298
- };
299
- }
300
-
301
- //toDataURL 是同步编码,一整页 JPEG 能顶住主线程几百毫秒;
302
- //toBlob 是异步的,浏览器可以把编码挪到主线程之外,转圈动画就不会整段定住。
303
- //jsdom 和老浏览器没有 toBlob,退回同步版本。
304
- function encodeJpeg(canvas) {
305
- return new Promise((resolve) => {
306
- const sync = () => resolve(canvas.toDataURL("image/jpeg", JPEG_QUALITY));
307
- if (typeof canvas.toBlob !== "function") return sync();
308
- canvas.toBlob(
309
- (blob) => {
310
- if (!blob) return sync();
311
- const reader = new FileReader();
312
- reader.onload = () => resolve(reader.result);
313
- reader.onerror = sync;
314
- reader.readAsDataURL(blob);
315
- },
316
- "image/jpeg",
317
- JPEG_QUALITY
318
- );
319
- });
320
- }
321
-
322
- async function canvasToPdf(canvas, fileName, loading) {
323
- const pdf = new jspdf("p", "mm", "a4");
324
- //按 A4 正文区的宽高比换算一页对应的像素高度
325
- const pageImgHeight = Math.floor((PAGE_HEIGHT * canvas.width) / PAGE_WIDTH);
326
- const total = Math.ceil(canvas.height / pageImgHeight);
327
- let page$index = 0;
328
- let rendered = 0;
329
- while (rendered < canvas.height) {
330
- page$index++;
331
- if (loading) loading.setText(`正在生成第 ${page$index}/${total} 页…`);
332
- //每页之前让出一帧:切图+编码是大块同步活,不让出的话文案和转圈都刷不出来
333
- await nextFrame();
334
- const sliceHeight = Math.min(pageImgHeight, canvas.height - rendered);
335
- const page = document.createElement("canvas");
336
- page.width = canvas.width;
337
- page.height = sliceHeight;
338
- const ctx = page.getContext("2d");
339
- //JPEG 没有透明通道,不先铺白底的话,页面上透明的地方会变成黑块
340
- ctx.fillStyle = "#ffffff";
341
- ctx.fillRect(0, 0, page.width, page.height);
342
- //用 drawImage 而不是 getImageData/putImageData:后者逐像素拷贝,长页面上很慢
343
- ctx.drawImage(
344
- canvas,
345
- 0,
346
- rendered,
347
- canvas.width,
348
- sliceHeight,
349
- 0,
350
- 0,
351
- canvas.width,
352
- sliceHeight
353
- );
354
- const dataUrl = await encodeJpeg(page);
355
- pdf.addImage(
356
- dataUrl,
357
- "JPEG",
358
- PAGE_MARGIN,
359
- PAGE_MARGIN,
360
- PAGE_WIDTH,
361
- Math.min(PAGE_HEIGHT, (PAGE_WIDTH * sliceHeight) / page.width)
362
- );
363
- rendered += sliceHeight;
364
- if (rendered < canvas.height) pdf.addPage();
365
- }
366
- pdf.save(`${fileName}.pdf`);
367
- }
368
-
369
- /**
370
- * 把 dom 整体(含被滚动容器裁掉的部分)截图并按 A4 分页导出 PDF
371
- * @param {Object} option
372
- * @param {HTMLElement} option.dom 要导出的根元素
373
- * @param {String} option.fileName 文件名(不带扩展名)
374
- * @param {Number} [option.scale] 截图倍率,默认取 devicePixelRatio(上限 2),会按 canvas 上限自动回退
375
- * @param {String|String[]} [option.ignoreSelectors] 不参与截图的元素选择器,如操作按钮区
376
- * @param {Boolean} [option.loading] 是否在导出期间盖加载遮罩,默认 true
377
- * @param {String} [option.loadingText] 遮罩文案
378
- * @returns {Promise<Boolean>} 是否导出成功。内部已经提示过失败,调用方可以直接不管返回值
379
- */
380
- export async function exportPDF(option) {
381
- const dom = option && option.dom;
382
- if (!dom) return false;
383
- const fileName = option.fileName || "export";
384
- const loading = openLoading(dom, option);
385
- //展开放进 try 里:万一它自己抛了,遮罩也得摘掉,不能把页面永远糊住
386
- let restore = null;
387
- try {
388
- //先让浏览器把遮罩画出来,再做展开和截图——这两步都是同步卡主线程的,
389
- //不先出让一帧的话遮罩要等截图结束才显示,等于没有。
390
- await nextFrame();
391
- restore = expandForCapture(dom);
392
- if (loading) loading.setText("正在展开页面内容…");
393
- await nextFrame();
394
- const fullWidth = Math.max(dom.scrollWidth, dom.offsetWidth);
395
- const fullHeight = Math.max(dom.scrollHeight, dom.offsetHeight);
396
- const scale = pickScale(fullWidth, fullHeight, option.scale);
397
- //html2canvas 这一段是整块同步活:它对每个节点都要 getComputedStyle 三次(自身/:before/:after)
398
- //克隆一遍,再整棵树画进 canvas,中途没有让出主线程的机会,页面必然定住几秒。
399
- //只能靠文案说明进度,真要更快就得降 scale 或少截点内容。
400
- if (loading) loading.setText("正在渲染页面,请稍候…");
401
- await nextFrame();
402
- const options = {
403
- backgroundColor: "#ffffff",
404
- useCORS: true,
405
- logging: false,
406
- scale: scale,
407
- //克隆出来的 iframe 默认只有一屏高,页面里的 100vh / height:100% 会在克隆里重新把内容压回一屏,
408
- //所以要把它撑到完整内容高度;scrollX/scrollY 归零避免按当前滚动位置偏移取景。
409
- scrollX: 0,
410
- scrollY: 0,
411
- windowWidth: Math.max(document.documentElement.clientWidth, fullWidth),
412
- windowHeight: fullHeight + 200,
413
- ignoreElements: buildIgnore(option.ignoreSelectors),
414
- };
415
- //WebKit 上直接带着颜色换算跑:先失败一次再重来,等于把那几秒的冻结再来一遍
416
- const canvas = await renderCanvas(dom, options, isWebKit());
417
- await canvasToPdf(canvas, fileName, loading);
418
- return true;
419
- } catch (err) {
420
- //不往外抛:调用方多是 @click 直接调用,抛出去只会变成一条 unhandledrejection
421
- console.error("[exportPDF] 导出失败", err);
422
- Message.error("导出PDF失败");
423
- return false;
424
- } finally {
425
- //必须等 html2canvas 真正 resolve 之后再还原:它的 DOM 克隆是异步做的
426
- if (restore) restore();
427
- if (loading) loading.close();
428
- }
429
- }
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
+ //Safari(WebKit) 的 getComputedStyle 会把颜色序列化成 color(srgb …) / color(display-p3 …),
110
+ //而 html2canvas 1.4.1 只认 rgb/rgba/hsl/hsla 四种,撞上就抛
111
+ //"Attempting to parse an unsupported color function" 直接整个导出失败。
112
+ //项目源码里并没有写过 color(),是浏览器自己吐出来的,改样式没用——只能在克隆体上就地换算。
113
+ const COLOR_FUNCTION = /color\([^()]*\)/g;
114
+
115
+ //html2canvas 会按颜色解析的属性(format: 'color'),外加三个值里可能夹着颜色的:
116
+ //渐变、阴影里的 color() 一样会把解析器打挂。
117
+ const COLOR_PROPS = [
118
+ "color",
119
+ "background-color",
120
+ "border-top-color",
121
+ "border-right-color",
122
+ "border-bottom-color",
123
+ "border-left-color",
124
+ "text-decoration-color",
125
+ "-webkit-text-stroke-color",
126
+ "background-image",
127
+ "box-shadow",
128
+ "text-shadow",
129
+ ];
130
+
131
+ //探针色:用来判断 canvas 到底认没认这个值(不认的话 fillStyle 会保持原样)
132
+ const PROBE_COLOR = "#010203";
133
+
134
+ function isWebKit() {
135
+ const ua = typeof navigator === "undefined" ? "" : navigator.userAgent || "";
136
+ //Chrome 系的 UA 里也有 AppleWebKit 字样,但它不会这么序列化,得排掉
137
+ return /AppleWebKit/.test(ua) && !/Chrome|Chromium|Edg\//.test(ua);
138
+ }
139
+
140
+ function probeContext() {
141
+ try {
142
+ const canvas = document.createElement("canvas");
143
+ return typeof canvas.getContext === "function" ? canvas.getContext("2d") : null;
144
+ } catch (err) {
145
+ return null;
146
+ }
147
+ }
148
+
149
+ function toByte(raw) {
150
+ const num = parseFloat(raw);
151
+ if (isNaN(num)) return 0;
152
+ const ratio = raw.indexOf("%") === -1 ? num : num / 100;
153
+ return Math.max(0, Math.min(255, Math.round(ratio * 255)));
154
+ }
155
+
156
+ function toAlpha(raw) {
157
+ const num = parseFloat(raw);
158
+ if (isNaN(num)) return 1;
159
+ const alpha = raw.indexOf("%") === -1 ? num : num / 100;
160
+ return Math.max(0, Math.min(1, alpha));
161
+ }
162
+
163
+ //浏览器不肯换算时的兜底:按 sRGB 直接取分量。
164
+ //display-p3 这样算是近似(色域没做映射),但截图里看不出差别,总好过整个导出失败。
165
+ function fallbackColor(value) {
166
+ const inside = value.slice(value.indexOf("(") + 1, value.lastIndexOf(")"));
167
+ const parts = inside.split("/");
168
+ //第一段是色彩空间名(srgb / display-p3 …),分量从第二段开始
169
+ const numbers = parts[0].trim().split(/\s+/).slice(1);
170
+ const channels = [0, 1, 2].map((i) => toByte(numbers[i] || "0"));
171
+ const alpha = parts.length > 1 ? toAlpha(parts[1]) : 1;
172
+ return "rgba(" + channels.join(", ") + ", " + alpha + ")";
173
+ }
174
+
175
+ function toLegacyColor(value, ctx) {
176
+ return value.replace(COLOR_FUNCTION, (match) => {
177
+ //优先让浏览器自己换:它认识 color(),换出来的 sRGB 值是准的
178
+ if (ctx) {
179
+ ctx.fillStyle = PROBE_COLOR;
180
+ ctx.fillStyle = match;
181
+ const converted = ctx.fillStyle;
182
+ const usable = typeof converted === "string" && converted.indexOf("color(") === -1;
183
+ if (usable && converted !== PROBE_COLOR) return converted;
184
+ }
185
+ return fallbackColor(match);
186
+ });
187
+ }
188
+
189
+ //在 html2canvas 的克隆体上把 color() 全部换成 rgba(),写成内联样式(优先级最高)。
190
+ //只在需要时才挂上去:这一趟要给每个节点再算一次样式,白跑一遍就是白卡一下。
191
+ export function normalizeColorFunctions(clonedDoc, clonedElement) {
192
+ const view = clonedDoc.defaultView;
193
+ if (!view || typeof view.getComputedStyle !== "function") return;
194
+ const ctx = probeContext();
195
+ const targets = [];
196
+ //除了目标元素,html2canvas 还会读 html/body 的背景色(parseBackgroundColor),一起带上
197
+ if (clonedDoc.documentElement) targets.push(clonedDoc.documentElement);
198
+ if (clonedDoc.body) targets.push(clonedDoc.body);
199
+ targets.push(clonedElement);
200
+ const children = clonedElement.querySelectorAll("*");
201
+ for (let i = 0; i < children.length; i++) targets.push(children[i]);
202
+
203
+ for (let i = 0; i < targets.length; i++) {
204
+ const el = targets[i];
205
+ if (!el || !el.style) continue;
206
+ const style = view.getComputedStyle(el);
207
+ for (let j = 0; j < COLOR_PROPS.length; j++) {
208
+ const prop = COLOR_PROPS[j];
209
+ const value = style.getPropertyValue(prop);
210
+ if (!value || value.indexOf("color(") === -1) continue;
211
+ el.style.setProperty(prop, toLegacyColor(value, ctx));
212
+ }
213
+ }
214
+ }
215
+
216
+ function isUnsupportedColorError(err) {
217
+ const message = err && err.message ? err.message : String(err || "");
218
+ return message.indexOf("unsupported color function") !== -1;
219
+ }
220
+
221
+ function withColorFix(options) {
222
+ return Object.assign({}, options, { onclone: normalizeColorFunctions });
223
+ }
224
+
225
+ async function renderCanvas(dom, options, fixColors) {
226
+ try {
227
+ return await html2canvas(dom, fixColors ? withColorFix(options) : options);
228
+ } catch (err) {
229
+ //别的引擎哪天也开始这么序列化,兜一次;已经带着换算跑过就别再重来了
230
+ if (fixColors || !isUnsupportedColorError(err)) throw err;
231
+ return html2canvas(dom, withColorFix(options));
232
+ }
233
+ }
234
+
235
+ function buildIgnore(selectors) {
236
+ let list = [LOADING_MASK];
237
+ if (selectors) list = list.concat(selectors);
238
+ const selector = list.join(",");
239
+ return function (el) {
240
+ return typeof el.matches === "function" && el.matches(selector);
241
+ };
242
+ }
243
+
244
+ //手写一层 element 样式的遮罩,而不是用 Loading.service,两个原因都躲不掉:
245
+ //1. `Loading.service({ fullscreen: true })` 是全局单例,会被 request.js 里的请求收尾顺手关掉,
246
+ // 而导出这几秒里页面上别的请求随时可能回来;
247
+ //2. `Loading.service({ target: dom })` 会给 dom 加 el-loading-parent--relative(position:relative !important),
248
+ // 页面里 .slide-nav 这类绝对定位元素的包含块跟着变,位置会挪——而且是连截进 PDF 里的。
249
+ //自己建的 DOM 节点两头都不沾,也不用担心被别处关掉。
250
+ //挂载点取根元素的父级,不挂 body:微前端下 body 是主应用的,往上加节点算越界;
251
+ //挂父级也让这个节点跟着页面走,页面被销毁时不会剩一层孤儿遮罩。
252
+ //只遮导出的那块区域,不铺满视口:左侧菜单、页签这些跟导出无关的地方不该被压住。
253
+ //做法是 fixed + 按导出元素的 getBoundingClientRect 圈定范围(取展开之前的可视位置),
254
+ //这样既不用给挂载点加 position:relative(会挪走 .slide-nav 这类绝对定位元素,
255
+ //也正是不能用 Loading.service({ target }) 的原因),也不会盖到别人头上。
256
+ //为什么不挂根元素自己:加子节点会改变根元素下 :last-child 的匹配,
257
+ //而那种样式差异是会被截进 PDF 的;挂在父级上则完全在截图范围之外。
258
+ function openLoading(dom, option) {
259
+ if (option.loading === false) return null;
260
+ const host = dom.parentElement || dom;
261
+ //必须在 expandForCapture 之前量:展开之后元素会被撑到完整内容高度,量出来是没用的
262
+ const rect = dom.getBoundingClientRect();
263
+ const top = Math.max(rect.top, 0);
264
+ const left = Math.max(rect.left, 0);
265
+ const height = Math.min(rect.bottom, window.innerHeight) - top;
266
+ const width = Math.min(rect.right, window.innerWidth) - left;
267
+ const mask = document.createElement("div");
268
+ mask.className = "el-loading-mask";
269
+ //.el-loading-mask 自带 top/right/bottom/left:0,right/bottom 要显式清掉,
270
+ //否则宽高会被拉回视口边缘,又变成全屏
271
+ mask.style.position = "fixed";
272
+ mask.style.top = top + "px";
273
+ mask.style.left = left + "px";
274
+ mask.style.right = "auto";
275
+ mask.style.bottom = "auto";
276
+ mask.style.width = width + "px";
277
+ mask.style.height = height + "px";
278
+ mask.innerHTML = [
279
+ '<div class="el-loading-spinner">',
280
+ '<svg class="circular" viewBox="25 25 50 50">',
281
+ '<circle class="path" cx="50" cy="50" r="20" fill="none"></circle>',
282
+ "</svg>",
283
+ '<p class="el-loading-text"></p>',
284
+ "</div>",
285
+ ].join("");
286
+ const label = mask.querySelector(".el-loading-text");
287
+ label.textContent = option.loadingText || "正在生成PDF,请稍候…";
288
+ host.appendChild(mask);
289
+ return {
290
+ //分阶段换文案:截图那段主线程是整块占住的,转圈会定住,
291
+ //有个往前走的文案才看得出是在干活而不是卡死。调用方自带文案时不覆盖。
292
+ setText(next) {
293
+ if (!option.loadingText) label.textContent = next;
294
+ },
295
+ close() {
296
+ if (mask.parentNode) mask.parentNode.removeChild(mask);
297
+ },
298
+ };
299
+ }
300
+
301
+ //toDataURL 是同步编码,一整页 JPEG 能顶住主线程几百毫秒;
302
+ //toBlob 是异步的,浏览器可以把编码挪到主线程之外,转圈动画就不会整段定住。
303
+ //jsdom 和老浏览器没有 toBlob,退回同步版本。
304
+ function encodeJpeg(canvas) {
305
+ return new Promise((resolve) => {
306
+ const sync = () => resolve(canvas.toDataURL("image/jpeg", JPEG_QUALITY));
307
+ if (typeof canvas.toBlob !== "function") return sync();
308
+ canvas.toBlob(
309
+ (blob) => {
310
+ if (!blob) return sync();
311
+ const reader = new FileReader();
312
+ reader.onload = () => resolve(reader.result);
313
+ reader.onerror = sync;
314
+ reader.readAsDataURL(blob);
315
+ },
316
+ "image/jpeg",
317
+ JPEG_QUALITY
318
+ );
319
+ });
320
+ }
321
+
322
+ async function canvasToPdf(canvas, fileName, loading) {
323
+ const pdf = new jspdf("p", "mm", "a4");
324
+ //按 A4 正文区的宽高比换算一页对应的像素高度
325
+ const pageImgHeight = Math.floor((PAGE_HEIGHT * canvas.width) / PAGE_WIDTH);
326
+ const total = Math.ceil(canvas.height / pageImgHeight);
327
+ let page$index = 0;
328
+ let rendered = 0;
329
+ while (rendered < canvas.height) {
330
+ page$index++;
331
+ if (loading) loading.setText(`正在生成第 ${page$index}/${total} 页…`);
332
+ //每页之前让出一帧:切图+编码是大块同步活,不让出的话文案和转圈都刷不出来
333
+ await nextFrame();
334
+ const sliceHeight = Math.min(pageImgHeight, canvas.height - rendered);
335
+ const page = document.createElement("canvas");
336
+ page.width = canvas.width;
337
+ page.height = sliceHeight;
338
+ const ctx = page.getContext("2d");
339
+ //JPEG 没有透明通道,不先铺白底的话,页面上透明的地方会变成黑块
340
+ ctx.fillStyle = "#ffffff";
341
+ ctx.fillRect(0, 0, page.width, page.height);
342
+ //用 drawImage 而不是 getImageData/putImageData:后者逐像素拷贝,长页面上很慢
343
+ ctx.drawImage(
344
+ canvas,
345
+ 0,
346
+ rendered,
347
+ canvas.width,
348
+ sliceHeight,
349
+ 0,
350
+ 0,
351
+ canvas.width,
352
+ sliceHeight
353
+ );
354
+ const dataUrl = await encodeJpeg(page);
355
+ pdf.addImage(
356
+ dataUrl,
357
+ "JPEG",
358
+ PAGE_MARGIN,
359
+ PAGE_MARGIN,
360
+ PAGE_WIDTH,
361
+ Math.min(PAGE_HEIGHT, (PAGE_WIDTH * sliceHeight) / page.width)
362
+ );
363
+ rendered += sliceHeight;
364
+ if (rendered < canvas.height) pdf.addPage();
365
+ }
366
+ pdf.save(`${fileName}.pdf`);
367
+ }
368
+
369
+ /**
370
+ * 把 dom 整体(含被滚动容器裁掉的部分)截图并按 A4 分页导出 PDF
371
+ * @param {Object} option
372
+ * @param {HTMLElement} option.dom 要导出的根元素
373
+ * @param {String} option.fileName 文件名(不带扩展名)
374
+ * @param {Number} [option.scale] 截图倍率,默认取 devicePixelRatio(上限 2),会按 canvas 上限自动回退
375
+ * @param {String|String[]} [option.ignoreSelectors] 不参与截图的元素选择器,如操作按钮区
376
+ * @param {Boolean} [option.loading] 是否在导出期间盖加载遮罩,默认 true
377
+ * @param {String} [option.loadingText] 遮罩文案
378
+ * @returns {Promise<Boolean>} 是否导出成功。内部已经提示过失败,调用方可以直接不管返回值
379
+ */
380
+ export async function exportPDF(option) {
381
+ const dom = option && option.dom;
382
+ if (!dom) return false;
383
+ const fileName = option.fileName || "export";
384
+ const loading = openLoading(dom, option);
385
+ //展开放进 try 里:万一它自己抛了,遮罩也得摘掉,不能把页面永远糊住
386
+ let restore = null;
387
+ try {
388
+ //先让浏览器把遮罩画出来,再做展开和截图——这两步都是同步卡主线程的,
389
+ //不先出让一帧的话遮罩要等截图结束才显示,等于没有。
390
+ await nextFrame();
391
+ restore = expandForCapture(dom);
392
+ if (loading) loading.setText("正在展开页面内容…");
393
+ await nextFrame();
394
+ const fullWidth = Math.max(dom.scrollWidth, dom.offsetWidth);
395
+ const fullHeight = Math.max(dom.scrollHeight, dom.offsetHeight);
396
+ const scale = pickScale(fullWidth, fullHeight, option.scale);
397
+ //html2canvas 这一段是整块同步活:它对每个节点都要 getComputedStyle 三次(自身/:before/:after)
398
+ //克隆一遍,再整棵树画进 canvas,中途没有让出主线程的机会,页面必然定住几秒。
399
+ //只能靠文案说明进度,真要更快就得降 scale 或少截点内容。
400
+ if (loading) loading.setText("正在渲染页面,请稍候…");
401
+ await nextFrame();
402
+ const options = {
403
+ backgroundColor: "#ffffff",
404
+ useCORS: true,
405
+ logging: false,
406
+ scale: scale,
407
+ //克隆出来的 iframe 默认只有一屏高,页面里的 100vh / height:100% 会在克隆里重新把内容压回一屏,
408
+ //所以要把它撑到完整内容高度;scrollX/scrollY 归零避免按当前滚动位置偏移取景。
409
+ scrollX: 0,
410
+ scrollY: 0,
411
+ windowWidth: Math.max(document.documentElement.clientWidth, fullWidth),
412
+ windowHeight: fullHeight + 200,
413
+ ignoreElements: buildIgnore(option.ignoreSelectors),
414
+ };
415
+ //WebKit 上直接带着颜色换算跑:先失败一次再重来,等于把那几秒的冻结再来一遍
416
+ const canvas = await renderCanvas(dom, options, isWebKit());
417
+ await canvasToPdf(canvas, fileName, loading);
418
+ return true;
419
+ } catch (err) {
420
+ //不往外抛:调用方多是 @click 直接调用,抛出去只会变成一条 unhandledrejection
421
+ console.error("[exportPDF] 导出失败", err);
422
+ Message.error("导出PDF失败");
423
+ return false;
424
+ } finally {
425
+ //必须等 html2canvas 真正 resolve 之后再还原:它的 DOM 克隆是异步做的
426
+ if (restore) restore();
427
+ if (loading) loading.close();
428
+ }
429
+ }