gcs-ui-lib 1.2.34 → 1.2.35

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 (34) hide show
  1. package/lib/gcs-ui-lib.common.js +3493 -4211
  2. package/lib/gcs-ui-lib.css +1 -1
  3. package/lib/gcs-ui-lib.umd.js +3493 -4211
  4. package/lib/gcs-ui-lib.umd.min.js +85 -85
  5. package/package.json +2 -9
  6. package/packages/AutoFillDetail/src/main.vue +4 -4
  7. package/packages/AutoFillDetection/src/components/SelectEntries.vue +1 -1
  8. package/packages/AutoFillDetection/src/components/THIRD_ACCOUNT.vue +1 -1
  9. package/packages/AutoFillDetection/src/components/config.js +68 -68
  10. package/packages/AutoFillDetection/src/main.vue +1 -1
  11. package/packages/AutoFillList/src/components/config.js +11 -11
  12. package/packages/AutoFillList/src/main.vue +2 -2
  13. package/packages/AutoFillRuleHistory/src/components/config.js +14 -14
  14. package/packages/AutoFillRuleHistory/src/main.vue +1 -1
  15. package/packages/AutoFillService/src/components/ConditionGroup.vue +18 -18
  16. package/packages/AutoFillService/src/components/basic.vue +2 -2
  17. package/packages/AutoFillService/src/components/config.js +1 -1
  18. package/packages/AutoFillService/src/components/fillDetail.vue +8 -8
  19. package/packages/AutoFillService/src/components/fillRules.vue +5 -5
  20. package/packages/AutoFillService/src/components/paymentCategory.vue +1 -1
  21. package/packages/MergeAutoFill/merge/merageBasic.vue +3 -3
  22. package/packages/MergeAutoFill/merge/merageFillService.vue +2 -2
  23. package/packages/MergeAutoFill/merge/merageHistoryRules.vue +1 -1
  24. package/packages/MergeAutoFill/merge/mergeFillDetail.vue +4 -4
  25. package/packages/StreamRefill/src/main.vue +1 -1
  26. package/packages/Trade/src/components/i18n.json +3337 -3337
  27. package/src/index.js +8 -1
  28. package/src/lang/i18n.json +1184 -0
  29. package/src/lang/mergeI18n.js +27 -0
  30. package/src/preview/router.js +0 -5
  31. package/src/utils/exportPageSnapshot.js +728 -1037
  32. package/packages/ExportPageSnapshot/index.js +0 -7
  33. package/packages/ExportPageSnapshot/src/demo/index.vue +0 -352
  34. package/packages/ExportPageSnapshot/src/main.vue +0 -108
@@ -1,1037 +1,728 @@
1
- /**
2
- * 高保真页面 HTML 快照导出(Vue2 页面适用)
3
- *
4
- * 相比 gcs-ui-lib/exportPageSnapshot:
5
- * - 不做 CSS 裁剪,保留文档全部可访问样式
6
- * - 克隆节点后内联关键 computed 布局样式,提升离线还原度
7
- * - 内嵌图标字体(woff/woff2),跳过大体积宋体
8
- * - 默认不注入 PDF 专用覆盖样式,避免表格/字号被压扁
9
- * - 支持将生成的 HTML 快照转为 PDF(html2canvas + jsPDF)
10
- */
11
-
12
- export const PAGE_SNAPSHOT_BACKEND_PLACEHOLDER = '<!-- PAGE_SNAPSHOT_BACKEND_PLACEHOLDER -->'
13
-
14
- /** 页面内「导出快照」按钮 class,克隆时自动排除 */
15
- export const PAGE_SNAPSHOT_BTN_CLASS = 'page-snapshot-btn'
16
-
17
- const SNAPSHOT_FILENAME_PREFIX = 'page-snapshot'
18
- const SKIP_TAGS = new Set(['script', 'style', 'link', 'noscript', 'svg'])
19
- const SKIP_CLASSES = [
20
- PAGE_SNAPSHOT_BTN_CLASS,
21
- 'page-button-shadow',
22
- 'detpl-form-operate-box',
23
- 'self-footer'
24
- ]
25
-
26
- const SCROLL_UNWRAP_SELECTORS = [
27
- '.el-table__body-wrapper',
28
- '.el-table__fixed-body-wrapper',
29
- '.el-scrollbar__wrap',
30
- '.el-scrollbar__view',
31
- '.vxe-table--body-wrapper',
32
- '.vxe-table--body-inner-wrapper',
33
- '.vxe-table--main-wrapper'
34
- ]
35
-
36
- /** 内联 computed 样式时关注的属性(布局 + 常见视觉) */
37
- const INLINE_STYLE_PROPS = [
38
- 'display', 'flex', 'flex-direction', 'flex-wrap', 'flex-grow', 'flex-shrink', 'flex-basis',
39
- 'align-items', 'align-self', 'align-content', 'justify-content', 'justify-self', 'gap', 'row-gap', 'column-gap',
40
- 'grid-template-columns', 'grid-template-rows', 'grid-column', 'grid-row',
41
- 'position', 'top', 'right', 'bottom', 'left', 'z-index', 'float', 'clear',
42
- 'width', 'height', 'min-width', 'min-height', 'max-width', 'max-height',
43
- 'margin', 'margin-top', 'margin-right', 'margin-bottom', 'margin-left',
44
- 'padding', 'padding-top', 'padding-right', 'padding-bottom', 'padding-left',
45
- 'box-sizing', 'overflow', 'overflow-x', 'overflow-y',
46
- 'border', 'border-top', 'border-right', 'border-bottom', 'border-left',
47
- 'border-radius', 'border-collapse', 'table-layout',
48
- 'background', 'background-color', 'background-image', 'background-size', 'background-position',
49
- 'color', 'font-size', 'font-weight', 'font-style', 'line-height', 'letter-spacing',
50
- 'text-align', 'text-decoration', 'vertical-align', 'white-space', 'word-break', 'word-wrap',
51
- 'opacity', 'visibility', 'box-shadow', 'transform'
52
- ]
53
-
54
- const SNAPSHOT_BASE_CSS = `
55
- html, body {
56
- margin: 0;
57
- padding: 8px;
58
- background: #fff;
59
- -webkit-print-color-adjust: exact;
60
- print-color-adjust: exact;
61
- }
62
-
63
- n20-page,
64
- [class$="-wrap"],
65
- .n20-page-content,
66
- .page-content,
67
- .action-parse-form-container {
68
- height: auto !important;
69
- max-height: none !important;
70
- overflow: visible !important;
71
- }
72
-
73
- .el-dialog__wrapper,
74
- .el-message,
75
- .el-message-box__wrapper,
76
- .el-notification,
77
- .el-loading-mask,
78
- .page-snapshot-btn,
79
- .page-button-shadow {
80
- display: none !important;
81
- }
82
-
83
- /* 快照需展示全部表格行,解除 flex 布局下的可视区高度限制 */
84
- .table-wrap,
85
- .table-wrap .table-content {
86
- flex: none !important;
87
- position: relative !important;
88
- height: auto !important;
89
- min-height: 0 !important;
90
- max-height: none !important;
91
- overflow: visible !important;
92
- }
93
- .height-100 {
94
- height: auto !important;
95
- }
96
- .el-table,
97
- .el-table__header-wrapper,
98
- .el-table__body-wrapper,
99
- .el-table__footer-wrapper,
100
- .vxe-table,
101
- .vxe-table--body-wrapper,
102
- .vxe-table--main-wrapper {
103
- height: auto !important;
104
- max-height: none !important;
105
- overflow: visible !important;
106
- }
107
-
108
- /* 固定列浮层仅覆盖可视区,快照需移除并还原主表隐藏单元格 */
109
- .el-table__fixed,
110
- .el-table__fixed-right,
111
- .el-table__fixed-right-patch {
112
- display: none !important;
113
- }
114
- .el-table__cell.is-hidden,
115
- .el-table__cell.is-hidden > * {
116
- visibility: visible !important;
117
- }
118
- `
119
-
120
- const SNAPSHOT_PDF_CSS = `
121
- @page { size: A4 landscape; margin: 15mm; }
122
- html, body { font-family: SimSun, "Songti SC", "Microsoft YaHei", serif; font-size: 10px; line-height: 1.4; }
123
- td, th { white-space: normal !important; padding: 4px; font-size: 10px; vertical-align: top; }
124
- `
125
-
126
- function safeRun(fn, fallback, label) {
127
- try {
128
- return fn()
129
- } catch (e) {
130
- if (label) console.warn(`[exportPageHtml] ${label}`, e)
131
- return fallback
132
- }
133
- }
134
-
135
- function buildSnapshotFilename(ext = 'html') {
136
- const ts = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19)
137
- return `${SNAPSHOT_FILENAME_PREFIX}-${ts}.${ext}`
138
- }
139
-
140
- let pdfLibsPromise = null
141
-
142
- async function loadPdfLibraries() {
143
- if (pdfLibsPromise) return pdfLibsPromise
144
-
145
- pdfLibsPromise = Promise.all([
146
- import(/* webpackChunkName: "html2canvas" */ 'html2canvas'),
147
- import(/* webpackChunkName: "jspdf" */ 'jspdf')
148
- ]).then(([html2canvasMod, jsPDFMod]) => ({
149
- html2canvas: html2canvasMod.default || html2canvasMod,
150
- jsPDF: jsPDFMod.jsPDF || jsPDFMod.default || jsPDFMod
151
- })).catch((e) => {
152
- pdfLibsPromise = null
153
- throw e
154
- })
155
-
156
- return pdfLibsPromise
157
- }
158
-
159
- function mountHtmlIframe(html, captureWidth) {
160
- const width = captureWidth > 0
161
- ? captureWidth
162
- : (document.documentElement.clientWidth || 1400)
163
- return new Promise((resolve, reject) => {
164
- const iframe = document.createElement('iframe')
165
- iframe.setAttribute('aria-hidden', 'true')
166
- iframe.style.cssText = `position:fixed;left:-10000px;top:0;border:0;width:${width}px;height:900px;visibility:hidden;`
167
- document.body.appendChild(iframe)
168
-
169
- iframe.onload = async () => {
170
- try {
171
- const doc = iframe.contentDocument
172
- if (doc?.fonts?.ready) await doc.fonts.ready
173
- resizeIframeToContent(iframe)
174
- await new Promise((r) => setTimeout(r, 200))
175
- resolve(iframe)
176
- } catch (e) {
177
- reject(e)
178
- }
179
- }
180
- iframe.onerror = reject
181
- iframe.srcdoc = html
182
- })
183
- }
184
-
185
- function unmountHtmlIframe(iframe) {
186
- if (iframe?.parentNode) iframe.parentNode.removeChild(iframe)
187
- }
188
-
189
- /**
190
- * 将 HTML 快照字符串转为 PDF Blob
191
- * @param {string} html
192
- * @param {{ orientation?: string, format?: string, margin?: number, scale?: number, quality?: number, captureWidth?: number }} [options]
193
- */
194
- export async function convertHtmlSnapshotToPdf(html, options = {}) {
195
- const {
196
- orientation = 'landscape',
197
- format = 'a4',
198
- margin = 15,
199
- scale = 2,
200
- quality = 0.92,
201
- captureWidth = 0
202
- } = options
203
-
204
- let iframe
205
- try {
206
- const { html2canvas, jsPDF } = await loadPdfLibraries()
207
- iframe = await mountHtmlIframe(html, captureWidth)
208
- const body = iframe.contentDocument.body
209
- resizeIframeToContent(iframe)
210
- await new Promise((r) => setTimeout(r, 100))
211
-
212
- const shotWidth = iframe.clientWidth || body.offsetWidth
213
- const shotHeight = body.scrollHeight
214
-
215
- const canvas = await html2canvas(body, {
216
- scale,
217
- useCORS: true,
218
- logging: false,
219
- backgroundColor: '#ffffff',
220
- width: shotWidth,
221
- height: shotHeight,
222
- windowWidth: shotWidth,
223
- windowHeight: shotHeight
224
- })
225
-
226
- const pdf = new jsPDF({ orientation, unit: 'mm', format })
227
- const pageWidth = pdf.internal.pageSize.getWidth()
228
- const pageHeight = pdf.internal.pageSize.getHeight()
229
- const contentHeight = pageHeight - margin * 2
230
- const imgWidth = pageWidth - margin * 2
231
- const imgHeight = (canvas.height * imgWidth) / canvas.width
232
- const imgData = canvas.toDataURL('image/jpeg', quality)
233
-
234
- let heightLeft = imgHeight
235
- let offsetY = margin
236
-
237
- pdf.addImage(imgData, 'JPEG', margin, offsetY, imgWidth, imgHeight)
238
- heightLeft -= contentHeight
239
-
240
- while (heightLeft > 0) {
241
- offsetY = margin - (imgHeight - heightLeft)
242
- pdf.addPage(format, orientation)
243
- pdf.addImage(imgData, 'JPEG', margin, offsetY, imgWidth, imgHeight)
244
- heightLeft -= contentHeight
245
- }
246
-
247
- const blob = pdf.output('blob')
248
- const filename = buildSnapshotFilename('pdf')
249
- const file = new File([blob], filename, { type: 'application/pdf' })
250
-
251
- return { ok: true, blob, file, filename }
252
- } catch (e) {
253
- console.warn('[exportPageHtml] HTML 转 PDF 失败', e)
254
- return { ok: false, message: 'HTML 转 PDF 失败' }
255
- } finally {
256
- unmountHtmlIframe(iframe)
257
- }
258
- }
259
-
260
- function shouldSkipElement(el) {
261
- if (!el || el.nodeType !== Node.ELEMENT_NODE) return false
262
- if (SKIP_TAGS.has(el.tagName.toLowerCase())) return true
263
- return SKIP_CLASSES.some((cls) => el.classList?.contains(cls))
264
- }
265
-
266
- function clonePageNode(node) {
267
- if (!node) return null
268
-
269
- return safeRun(() => {
270
- if (node.nodeType === Node.TEXT_NODE) return node.cloneNode(false)
271
- if (node.nodeType !== Node.ELEMENT_NODE) return null
272
- if (shouldSkipElement(node)) return null
273
-
274
- const tag = node.tagName.toLowerCase()
275
- const cloned = node.cloneNode(false)
276
-
277
- Array.from(node.attributes || []).forEach((attr) => {
278
- safeRun(() => cloned.setAttribute(attr.name, attr.value), undefined)
279
- })
280
-
281
- if (tag === 'input') {
282
- cloned.setAttribute('value', node.value || '')
283
- if (node.type === 'checkbox' || node.type === 'radio') {
284
- if (node.checked) cloned.setAttribute('checked', 'checked')
285
- else cloned.removeAttribute('checked')
286
- }
287
- }
288
- if (tag === 'textarea') {
289
- cloned.textContent = node.value || ''
290
- }
291
- if (tag === 'select') {
292
- const idx = node.selectedIndex
293
- Array.from(cloned.options || []).forEach((opt, i) => {
294
- if (i === idx) opt.setAttribute('selected', 'selected')
295
- else opt.removeAttribute('selected')
296
- })
297
- }
298
-
299
- Array.from(node.childNodes).forEach((child) => {
300
- const childClone = clonePageNode(child)
301
- if (childClone) cloned.appendChild(childClone)
302
- })
303
- return cloned
304
- }, null, '克隆节点失败')
305
- }
306
-
307
- function unwrapScrollContainers(root) {
308
- if (!root?.querySelectorAll) return
309
- SCROLL_UNWRAP_SELECTORS.forEach((sel) => {
310
- root.querySelectorAll(sel).forEach((el) => {
311
- el.style.setProperty('max-height', 'none', 'important')
312
- el.style.setProperty('height', 'auto', 'important')
313
- el.style.setProperty('overflow', 'visible', 'important')
314
- })
315
- })
316
- }
317
-
318
- const TABLE_EXPAND_SELECTORS = [
319
- '.table-wrap',
320
- '.table-content',
321
- '.height-100',
322
- '.el-table',
323
- '.el-table__header-wrapper',
324
- '.el-table__body-wrapper',
325
- '.el-table__footer-wrapper',
326
- '.vxe-table',
327
- '.vxe-table--body-wrapper',
328
- '.vxe-table--main-wrapper'
329
- ]
330
-
331
- /** 克隆/iframe 内展开表格,避免只导出可视区行 */
332
- function expandTablesForSnapshot(root) {
333
- if (!root?.querySelectorAll) return
334
-
335
- unwrapScrollContainers(root)
336
-
337
- TABLE_EXPAND_SELECTORS.forEach((sel) => {
338
- root.querySelectorAll(sel).forEach((el) => {
339
- el.style.setProperty('height', 'auto', 'important')
340
- el.style.setProperty('max-height', 'none', 'important')
341
- el.style.setProperty('overflow', 'visible', 'important')
342
- el.style.setProperty('flex', 'none', 'important')
343
- })
344
- })
345
-
346
- root.querySelectorAll('.table-wrap').forEach((wrap) => {
347
- wrap.style.setProperty('position', 'relative', 'important')
348
- wrap.style.setProperty('min-height', '0', 'important')
349
- })
350
-
351
- root.querySelectorAll('.table-content').forEach((content) => {
352
- content.style.setProperty('position', 'relative', 'important')
353
- })
354
-
355
- root.querySelectorAll('.el-table').forEach((table) => {
356
- table.removeAttribute('height')
357
- })
358
- }
359
-
360
- /** Element UI 固定列:移除浮层克隆,显示主表中 is-hidden 的序号/复选框列 */
361
- function normalizeElTableFixedColumns(root) {
362
- if (!root?.querySelectorAll) return
363
-
364
- root.querySelectorAll(
365
- '.el-table__fixed, .el-table__fixed-right, .el-table__fixed-right-patch'
366
- ).forEach((el) => el.remove())
367
-
368
- root.querySelectorAll('.el-table__cell.is-hidden').forEach((cell) => {
369
- cell.classList.remove('is-hidden')
370
- cell.style.setProperty('visibility', 'visible', 'important')
371
- Array.from(cell.children).forEach((child) => {
372
- child.style.setProperty('visibility', 'visible', 'important')
373
- })
374
- })
375
- }
376
-
377
- function expandAndNormalizeTables(root) {
378
- expandTablesForSnapshot(root)
379
- normalizeElTableFixedColumns(root)
380
- }
381
-
382
- function resizeIframeToContent(iframe) {
383
- const body = iframe?.contentDocument?.body
384
- if (!body) return
385
- expandAndNormalizeTables(body)
386
- const height = Math.max(body.scrollHeight, body.offsetHeight, 400)
387
- iframe.style.height = `${height}px`
388
- }
389
-
390
- /**
391
- * 删除页面锚点导航 DOM(N20-anchor 右侧/左侧导航栏)
392
- * @param {HTMLElement} root
393
- */
394
- export function removeAnchorNav(root) {
395
- if (!root?.querySelectorAll) return
396
-
397
- root.querySelectorAll('.detpl-edit-form-container.flex-box').forEach((flexBox) => {
398
- Array.from(flexBox.children).forEach((child) => {
399
- if (child.classList.contains('flex-item')) return
400
- if (child.querySelector('.n20-anchor2-nav')) {
401
- child.remove()
402
- }
403
- })
404
- flexBox.style?.removeProperty('padding-right')
405
- })
406
-
407
- root.querySelectorAll('.n20-anchor, .n20-anchor-left, .n20-anchor2-sidebar').forEach((el) => {
408
- el.remove()
409
- })
410
-
411
- root.querySelectorAll('.n20-anchor2-nav').forEach((el) => {
412
- if (!el.closest('.flex-item')) {
413
- el.remove()
414
- }
415
- })
416
- }
417
-
418
- /** 底部固定提交/操作按钮组选择器 */
419
- const BOTTOM_OPERATE_SELECTORS = [
420
- '.page-button-shadow',
421
- '.detpl-form-operate-box',
422
- '.self-footer',
423
- '.page-footer-shadow'
424
- ]
425
-
426
- /**
427
- * 删除底部固定提交按钮组 DOM
428
- * @param {HTMLElement} root
429
- */
430
- export function removeBottomOperateButtons(root) {
431
- if (!root?.querySelectorAll) return
432
-
433
- BOTTOM_OPERATE_SELECTORS.forEach((selector) => {
434
- root.querySelectorAll(selector).forEach((el) => el.remove())
435
- })
436
- }
437
-
438
- function mergeInlineStyle(el, extraCss) {
439
- if (!extraCss) return
440
- const prev = el.getAttribute('style') || ''
441
- const merged = prev ? `${prev};${extraCss}` : extraCss
442
- el.setAttribute('style', merged)
443
- }
444
-
445
- function buildComputedStyleText(el) {
446
- const computed = window.getComputedStyle(el)
447
- const chunks = []
448
-
449
- INLINE_STYLE_PROPS.forEach((prop) => {
450
- const val = computed.getPropertyValue(prop)
451
- if (!val || val === 'initial' || val === 'auto' && prop !== 'height' && prop !== 'width') return
452
- if (prop === 'background-image' && val === 'none') return
453
- chunks.push(`${prop}:${val}`)
454
- })
455
- return chunks.join(';')
456
- }
457
-
458
- function inlineComputedStyles(sourceEl, cloneEl) {
459
- if (!sourceEl || !cloneEl || sourceEl.nodeType !== Node.ELEMENT_NODE) return
460
-
461
- mergeInlineStyle(cloneEl, buildComputedStyleText(sourceEl))
462
-
463
- const srcChildren = sourceEl.children || []
464
- const cloneChildren = cloneEl.children || []
465
- const len = Math.min(srcChildren.length, cloneChildren.length)
466
- for (let i = 0; i < len; i++) {
467
- inlineComputedStyles(srcChildren[i], cloneChildren[i])
468
- }
469
- }
470
-
471
- function normalizeCssChunk(text) {
472
- return (text || '').replace(/\s+/g, ' ').trim()
473
- }
474
-
475
- function collectDocumentCss() {
476
- const seen = new Set()
477
- const rules = []
478
- const processedStyleNodes = new Set()
479
-
480
- const addRule = (cssText) => {
481
- const chunk = normalizeCssChunk(cssText)
482
- if (!chunk || seen.has(chunk)) return
483
- seen.add(chunk)
484
- rules.push(cssText.trim())
485
- }
486
-
487
- safeRun(() => {
488
- Array.from(document.styleSheets || []).forEach((sheet) => {
489
- safeRun(() => {
490
- const sheetRules = sheet.cssRules || sheet.rules
491
- if (sheet.ownerNode) processedStyleNodes.add(sheet.ownerNode)
492
- if (!sheetRules) return
493
- Array.from(sheetRules).forEach((rule) => {
494
- safeRun(() => addRule(rule.cssText), undefined)
495
- })
496
- }, () => {
497
- if (sheet.ownerNode?.tagName === 'STYLE') {
498
- processedStyleNodes.add(sheet.ownerNode)
499
- addRule(sheet.ownerNode.textContent || '')
500
- }
501
- })
502
- })
503
- }, undefined, '遍历样式表失败')
504
-
505
- safeRun(() => {
506
- document.querySelectorAll('style').forEach((el) => {
507
- if (!processedStyleNodes.has(el)) addRule(el.textContent || '')
508
- })
509
- }, undefined, '收集 style 标签失败')
510
-
511
- return rules.join('\n')
512
- }
513
-
514
- function captureRootCssVariables() {
515
- return safeRun(() => {
516
- const styles = getComputedStyle(document.documentElement)
517
- const vars = []
518
- for (let i = 0; i < styles.length; i++) {
519
- const prop = styles[i]
520
- if (prop.startsWith('--')) {
521
- vars.push(`${prop}:${styles.getPropertyValue(prop)}`)
522
- }
523
- }
524
- return vars.length ? `:root { ${vars.join(';')} }` : ''
525
- }, '', '收集 CSS 变量失败')
526
- }
527
-
528
- function stripHeavyFontFaces(css) {
529
- return (css || '').replace(/@font-face\s*\{[^}]*font-family\s*:\s*SUN[^}]*\}/gi, '')
530
- }
531
-
532
- function getAppStaticBase() {
533
- const base = typeof process !== 'undefined' && process.env?.BASE_URL
534
- ? process.env.BASE_URL
535
- : '/lms/'
536
- try {
537
- return new URL(`static/`, new URL(base, window.location.origin)).href
538
- } catch (e) {
539
- return `${window.location.origin}/lms/static/`
540
- }
541
- }
542
-
543
- function getStylesheetBaseUrls() {
544
- const bases = new Set([getAppStaticBase(), `${window.location.origin}/`])
545
-
546
- safeRun(() => {
547
- document.querySelectorAll('link[rel="stylesheet"][href]').forEach((link) => {
548
- bases.add(new URL('.', link.href).href)
549
- })
550
- Array.from(document.styleSheets || []).forEach((sheet) => {
551
- if (!sheet.href) return
552
- bases.add(new URL('.', sheet.href).href)
553
- })
554
- }, undefined)
555
-
556
- return [...bases]
557
- }
558
-
559
- function getLoadedFontResourceUrls() {
560
- return safeRun(() => {
561
- return performance
562
- .getEntriesByType('resource')
563
- .filter((entry) => /\.(woff2?|ttf|otf)(\?|$)/i.test(entry.name))
564
- .map((entry) => entry.name)
565
- }, [], '读取已加载字体资源失败')
566
- }
567
-
568
- function toAbsoluteFontAssetUrl(assetUrl) {
569
- if (!assetUrl) return ''
570
- if (/^https?:\/\//i.test(assetUrl) || assetUrl.startsWith('data:')) return assetUrl
571
- try {
572
- return new URL(assetUrl, window.location.origin).href
573
- } catch (e) {
574
- return assetUrl
575
- }
576
- }
577
-
578
- function safeRequireFont(modulePath) {
579
- try {
580
- return toAbsoluteFontAssetUrl(require(modulePath))
581
- } catch (e) {
582
- return ''
583
- }
584
- }
585
-
586
- /** webpack 解析的图标字体 URL(/gdebit/static/fonts/...) */
587
- function getWebpackIconFontUrls() {
588
- const paths = [
589
- 'element-ui/lib/theme-chalk/fonts/element-icons.woff',
590
- 'element-ui/lib/theme-chalk/fonts/element-icons.ttf',
591
- 'n20-common-lib/src/assets/iconFont2/iconfont.woff2',
592
- 'n20-common-lib/src/assets/iconFont2/iconfont.woff',
593
- 'n20-common-lib/src/assets/iconFont2/iconfont.ttf'
594
- ]
595
-
596
- return [...new Set(paths.map(safeRequireFont).filter(Boolean))]
597
- }
598
-
599
- const ICON_FONT_FAMILIES = [
600
- 'element-icons',
601
- 'core-lib-iconfont',
602
- 'iconfont',
603
- 'vxeiconfont'
604
- ]
605
-
606
- function escapeRegExp(text) {
607
- return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
608
- }
609
-
610
- /** 导出前触发图标字体加载,便于 performance / document.fonts 拿到真实 URL */
611
- async function ensureIconFontsLoaded() {
612
- const probe = document.createElement('div')
613
- probe.style.cssText = 'position:absolute;left:-9999px;visibility:hidden;pointer-events:none'
614
- probe.innerHTML = [
615
- '<i class="el-icon-arrow-down"></i>',
616
- '<i class="el-icon-date"></i>',
617
- '<i class="n20-icon-yuefenqiehuan-zuoce"></i>',
618
- '<i class="vxe-icon-caret-down"></i>'
619
- ].join('')
620
-
621
- document.body.appendChild(probe)
622
-
623
- await safeRun(async () => {
624
- if (!document.fonts?.load) return
625
- const tasks = ICON_FONT_FAMILIES.map((family) => (
626
- document.fonts.load(`16px ${family}`).catch(() => {})
627
- ))
628
- await Promise.all(tasks)
629
- await document.fonts.ready
630
- }, undefined)
631
-
632
- await new Promise((resolve) => setTimeout(resolve, 120))
633
- document.body.removeChild(probe)
634
- }
635
-
636
- function collectFontUrlsFromStyleSheets() {
637
- const urls = new Set()
638
-
639
- safeRun(() => {
640
- Array.from(document.styleSheets || []).forEach((sheet) => {
641
- let rules
642
- try {
643
- rules = sheet.cssRules
644
- } catch (e) {
645
- return
646
- }
647
- if (!rules) return
648
-
649
- const base = sheet.href || window.location.href
650
- Array.from(rules).forEach((rule) => {
651
- if (rule.type !== CSSRule.FONT_FACE_RULE) return
652
- const cssText = rule.cssText || ''
653
- if (!/iconfont|element-icons|vxeicon/i.test(cssText)) return
654
- for (const match of cssText.matchAll(/url\(\s*["']?([^"')]+)["']?\s*\)/g)) {
655
- const raw = match[1].trim()
656
- if (raw.startsWith('data:')) {
657
- urls.add(raw)
658
- continue
659
- }
660
- safeRun(() => urls.add(new URL(raw, base).href))
661
- }
662
- })
663
- })
664
- }, undefined)
665
-
666
- return [...urls]
667
- }
668
-
669
- function getAllIconFontFetchUrls() {
670
- const urls = new Set()
671
-
672
- getWebpackIconFontUrls().forEach((url) => urls.add(url))
673
- getLoadedFontResourceUrls().forEach((url) => {
674
- if (/iconfont|element-icons|vxeicon|\.woff2?|\.ttf|\.otf/i.test(url)) {
675
- urls.add(url)
676
- }
677
- })
678
- collectFontUrlsFromStyleSheets().forEach((url) => urls.add(url))
679
-
680
- return [...urls]
681
- }
682
-
683
- function getFilename(url) {
684
- return (url || '').split('/').pop().split('?')[0]
685
- }
686
-
687
- function buildFontFetchCandidates(rawUrl) {
688
- if (!rawUrl || rawUrl.startsWith('data:') || rawUrl.startsWith('blob:')) {
689
- return rawUrl ? [rawUrl] : []
690
- }
691
-
692
- const candidates = new Set()
693
- const filename = getFilename(rawUrl)
694
-
695
- if (/^https?:\/\//i.test(rawUrl)) {
696
- candidates.add(rawUrl)
697
- }
698
-
699
- safeRun(() => candidates.add(new URL(rawUrl, window.location.href).href))
700
- getStylesheetBaseUrls().forEach((base) => {
701
- safeRun(() => candidates.add(new URL(rawUrl, base).href))
702
- })
703
-
704
- getAllIconFontFetchUrls().forEach((knownUrl) => {
705
- if (knownUrl.includes(filename)) {
706
- candidates.add(knownUrl)
707
- }
708
- })
709
-
710
- return [...candidates]
711
- }
712
-
713
- function isFontBinary(buffer) {
714
- if (!buffer || buffer.byteLength < 4) return false
715
- const bytes = new Uint8Array(buffer.slice(0, 4))
716
- const signature = String.fromCharCode(bytes[0], bytes[1], bytes[2], bytes[3])
717
- return (
718
- signature === 'wOFF'
719
- || signature === 'wOF2'
720
- || signature === 'OTTO'
721
- || (bytes[0] === 0x00 && bytes[1] === 0x01 && bytes[2] === 0x00 && bytes[3] === 0x00)
722
- )
723
- }
724
-
725
- function isFontDataUrl(dataUrl) {
726
- if (!dataUrl || typeof dataUrl !== 'string') return false
727
- if (!dataUrl.startsWith('data:')) return false
728
- if (/^data:text\/html/i.test(dataUrl)) return false
729
- if (/^data:application\/json/i.test(dataUrl)) return false
730
- if (/^data:image\//i.test(dataUrl)) return false
731
- return /^data:font\//i.test(dataUrl) || /^data:application\//i.test(dataUrl)
732
- }
733
-
734
- async function fetchFontAsDataUrl(rawUrl) {
735
- const candidates = buildFontFetchCandidates(rawUrl)
736
-
737
- for (const candidate of candidates) {
738
- if (candidate.startsWith('data:')) {
739
- if (isFontDataUrl(candidate)) return candidate
740
- continue
741
- }
742
-
743
- try {
744
- const resp = await fetch(candidate)
745
- if (!resp.ok) continue
746
-
747
- const contentType = resp.headers.get('content-type') || ''
748
- if (/text\/html|application\/json/i.test(contentType)) continue
749
-
750
- const buffer = await resp.arrayBuffer()
751
- if (!isFontBinary(buffer)) continue
752
-
753
- const blob = new Blob([buffer], {
754
- type: contentType || 'application/octet-stream'
755
- })
756
- const dataUrl = await new Promise((resolve, reject) => {
757
- const reader = new FileReader()
758
- reader.onload = () => resolve(reader.result)
759
- reader.onerror = reject
760
- reader.readAsDataURL(blob)
761
- })
762
-
763
- if (isFontDataUrl(dataUrl)) return dataUrl
764
- } catch (e) {
765
- // try next candidate
766
- }
767
- }
768
-
769
- throw new Error(`font fetch failed: ${rawUrl}`)
770
- }
771
-
772
- function shouldEmbedFontUrl(url) {
773
- if (!url || url.startsWith('data:')) return false
774
- if (/SIMSUN|simsun/i.test(url)) return false
775
- return /iconfont|element-icons|vxeicon|\.woff2?|\.ttf|\.otf/i.test(url)
776
- }
777
-
778
- async function embedIconFonts(css) {
779
- await ensureIconFontsLoaded()
780
-
781
- const filenameToData = new Map()
782
-
783
- for (const knownUrl of getAllIconFontFetchUrls()) {
784
- const filename = getFilename(knownUrl)
785
- if (!filename || filenameToData.has(filename)) continue
786
- if (knownUrl.startsWith('data:') && isFontDataUrl(knownUrl)) {
787
- filenameToData.set(filename, knownUrl)
788
- continue
789
- }
790
- try {
791
- filenameToData.set(filename, await fetchFontAsDataUrl(knownUrl))
792
- } catch (e) {
793
- // try next source
794
- }
795
- }
796
-
797
- const rawUrls = [...new Set(
798
- [...(css || '').matchAll(/url\(\s*["']?([^"')]+)["']?\s*\)/g)].map((m) => m[1].trim())
799
- )]
800
-
801
- for (const rawUrl of rawUrls) {
802
- if (!shouldEmbedFontUrl(rawUrl)) continue
803
- const filename = getFilename(rawUrl)
804
- if (filenameToData.has(filename)) continue
805
- try {
806
- filenameToData.set(filename, await fetchFontAsDataUrl(rawUrl))
807
- } catch (e) {
808
- console.warn('[exportPageHtml] 字体内嵌失败', rawUrl, e)
809
- }
810
- }
811
-
812
- let result = css
813
- filenameToData.forEach((dataUrl, filename) => {
814
- const pattern = new RegExp(
815
- `url\\(\\s*["']?[^"')]*${escapeRegExp(filename)}[^"')]*["']?\\s*\\)`,
816
- 'gi'
817
- )
818
- result = result.replace(pattern, `url("${dataUrl}")`)
819
- })
820
-
821
- result = result.replace(
822
- /url\(\s*["']?data:text\/html[^"')]+["']?\s*\)/gi,
823
- 'url("")'
824
- )
825
-
826
- return result
827
- }
828
-
829
- function downloadBlob(blob, filename) {
830
- safeRun(() => {
831
- const url = URL.createObjectURL(blob)
832
- const a = document.createElement('a')
833
- a.href = url
834
- a.download = filename
835
- document.body.appendChild(a)
836
- a.click()
837
- document.body.removeChild(a)
838
- URL.revokeObjectURL(url)
839
- }, undefined, '下载失败')
840
- }
841
-
842
- function normalizeOptions(options) {
843
- if (typeof options === 'boolean') {
844
- return { autoDownload: options, inlineStyles: false, embedFonts: true, forPdf: false, toPdf: false, pdfOptions: {} }
845
- }
846
- const merged = {
847
- autoDownload: false,
848
- inlineStyles: false,
849
- embedFonts: true,
850
- forPdf: false,
851
- toPdf: false,
852
- pdfOptions: {},
853
- ...(options || {})
854
- }
855
- if (merged.toPdf) merged.forPdf = true
856
- return merged
857
- }
858
-
859
- /**
860
- * 构建高保真页面 HTML 快照
861
- * @param {HTMLElement} rootEl
862
- * @param {{ autoDownload?: boolean, inlineStyles?: boolean, embedFonts?: boolean, forPdf?: boolean, toPdf?: boolean, pdfOptions?: object }} [options]
863
- */
864
- export async function buildPageHtmlSnapshot(rootEl, options = {}) {
865
- const { inlineStyles, embedFonts, forPdf } = normalizeOptions(options)
866
-
867
- if (!rootEl || rootEl.nodeType !== Node.ELEMENT_NODE) {
868
- return { ok: false, message: '未找到页面内容' }
869
- }
870
-
871
- try {
872
- if (embedFonts) {
873
- await ensureIconFontsLoaded()
874
- }
875
-
876
- const filename = buildSnapshotFilename()
877
- const pageTitle = document.title || SNAPSHOT_FILENAME_PREFIX
878
- const ts = filename.replace(`${SNAPSHOT_FILENAME_PREFIX}-`, '').replace('.html', '')
879
-
880
- const pageClone = clonePageNode(rootEl)
881
- if (!pageClone) return { ok: false, message: '克隆页面失败' }
882
-
883
- unwrapScrollContainers(pageClone)
884
- expandAndNormalizeTables(pageClone)
885
- removeAnchorNav(pageClone)
886
- removeBottomOperateButtons(pageClone)
887
-
888
- const pageHtml = pageClone.outerHTML
889
- if (!pageHtml) return { ok: false, message: '生成页面 HTML 失败' }
890
-
891
- let css = [
892
- captureRootCssVariables(),
893
- stripHeavyFontFaces(collectDocumentCss()),
894
- SNAPSHOT_BASE_CSS,
895
- forPdf ? SNAPSHOT_PDF_CSS : ''
896
- ].filter(Boolean).join('\n')
897
-
898
- if (embedFonts) {
899
- css = await embedIconFonts(css)
900
- }
901
-
902
- const html = `<!DOCTYPE html>
903
- <html lang="zh-CN">
904
- <head>
905
- <meta charset="UTF-8">
906
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
907
- <title>${pageTitle} - ${ts}</title>
908
- <style>
909
- ${css}
910
- </style>
911
- </head>
912
- <body>
913
- ${pageHtml}
914
- ${PAGE_SNAPSHOT_BACKEND_PLACEHOLDER}
915
- </body>
916
- </html>`
917
-
918
- const blob = new Blob([html], { type: 'text/html;charset=utf-8' })
919
- const file = new File([blob], filename, { type: blob.type })
920
-
921
- return { ok: true, html, filename, blob, file, ts, pageTitle }
922
- } catch (e) {
923
- console.warn('[exportPageHtml] 构建页面快照失败', e)
924
- return { ok: false, message: '构建页面快照失败' }
925
- }
926
- }
927
-
928
- /**
929
- * @param {HTMLElement} rootEl
930
- * @param {boolean|object} [options]
931
- */
932
- export async function exportPageHtml(rootEl, options = false) {
933
- const opts = normalizeOptions(options)
934
- if (opts.toPdf) return exportPagePdf(rootEl, opts)
935
-
936
- const { autoDownload, toPdf, pdfOptions, ...rest } = opts
937
- const result = await buildPageHtmlSnapshot(rootEl, rest)
938
-
939
- if (!result.ok) return result
940
- if (autoDownload) downloadBlob(result.blob, result.filename)
941
- return result
942
- }
943
-
944
- /**
945
- * 导出页面快照为 PDF(先构建 HTML,再转为 PDF)
946
- * @param {HTMLElement} rootEl
947
- * @param {boolean|object} [options]
948
- */
949
- export async function exportPagePdf(rootEl, options = false) {
950
- const { autoDownload, pdfOptions, toPdf, ...rest } = normalizeOptions({
951
- ...(typeof options === 'boolean' ? { autoDownload: options } : options),
952
- forPdf: true,
953
- toPdf: true
954
- })
955
-
956
- const htmlResult = await buildPageHtmlSnapshot(rootEl, rest)
957
- if (!htmlResult.ok) return htmlResult
958
-
959
- const captureWidth = Math.ceil(rootEl.getBoundingClientRect().width)
960
- || document.documentElement.clientWidth
961
- || 1400
962
- const pdfResult = await convertHtmlSnapshotToPdf(htmlResult.html, {
963
- ...pdfOptions,
964
- captureWidth
965
- })
966
- if (!pdfResult.ok) return pdfResult
967
-
968
- const result = {
969
- ok: true,
970
- html: htmlResult.html,
971
- htmlFilename: htmlResult.filename,
972
- htmlBlob: htmlResult.blob,
973
- htmlFile: htmlResult.file,
974
- filename: pdfResult.filename,
975
- blob: pdfResult.blob,
976
- file: pdfResult.file,
977
- ts: htmlResult.ts,
978
- pageTitle: htmlResult.pageTitle
979
- }
980
-
981
- if (autoDownload) downloadBlob(pdfResult.blob, pdfResult.filename)
982
- return result
983
- }
984
-
985
- /**
986
- * 点击导出:生成 PDF 并触发浏览器下载
987
- * @param {HTMLElement} rootEl
988
- * @param {object} [options]
989
- */
990
- export async function downloadPageSnapshotPdf(rootEl, options = {}) {
991
- return exportPagePdf(rootEl, { ...(options || {}), autoDownload: true })
992
- }
993
-
994
- /** @deprecated 兼容旧名,与 exportPageHtml 相同 */
995
- export const exportPageSnapshot = exportPageHtml
996
-
997
- /**
998
- * 上传用 FormData
999
- * @param {HTMLElement} rootEl
1000
- * @param {object} [options]
1001
- */
1002
- export async function createPageHtmlFormData(rootEl, options = {}) {
1003
- const opts = normalizeOptions(options)
1004
- if (opts.toPdf) {
1005
- const built = await exportPagePdf(rootEl, { ...opts, autoDownload: false })
1006
- if (!built.ok) return built
1007
-
1008
- return safeRun(() => {
1009
- const formData = new FormData()
1010
- formData.append('file', built.file, built.filename)
1011
- return {
1012
- ok: true,
1013
- formData,
1014
- file: built.file,
1015
- blob: built.blob,
1016
- filename: built.filename,
1017
- html: built.html
1018
- }
1019
- }, { ok: false, message: '构建上传数据失败' }, '构建 FormData 失败')
1020
- }
1021
-
1022
- const built = await buildPageHtmlSnapshot(rootEl, opts)
1023
- if (!built.ok) return built
1024
-
1025
- return safeRun(() => {
1026
- const formData = new FormData()
1027
- formData.append('file', built.file, built.filename)
1028
- return {
1029
- ok: true,
1030
- formData,
1031
- file: built.file,
1032
- blob: built.blob,
1033
- filename: built.filename,
1034
- html: built.html
1035
- }
1036
- }, { ok: false, message: '构建上传数据失败' }, '构建 FormData 失败')
1037
- }
1
+ /**
2
+ * 高保真页面 HTML 快照导出(Vue2 页面适用)
3
+ *
4
+ * 相比 gcs-ui-lib/exportPageSnapshot:
5
+ * - 不做 CSS 裁剪,保留文档全部可访问样式
6
+ * - 克隆节点后内联关键 computed 布局样式,提升离线还原度
7
+ * - 内嵌图标字体(woff/woff2),跳过大体积宋体
8
+ * - 默认不注入 PDF 专用覆盖样式,避免表格/字号被压扁
9
+ */
10
+
11
+ export const PAGE_SNAPSHOT_BACKEND_PLACEHOLDER = '<!-- PAGE_SNAPSHOT_BACKEND_PLACEHOLDER -->'
12
+
13
+ const SNAPSHOT_FILENAME_PREFIX = 'page-snapshot'
14
+ const SKIP_TAGS = new Set(['script', 'style', 'link', 'noscript', 'svg'])
15
+ const SKIP_CLASSES = [
16
+ 'page-snapshot-btn',
17
+ 'page-button-shadow',
18
+ 'detpl-form-operate-box',
19
+ 'self-footer'
20
+ ]
21
+
22
+ const SCROLL_UNWRAP_SELECTORS = [
23
+ '.el-table__body-wrapper',
24
+ '.el-table__fixed-body-wrapper',
25
+ '.el-scrollbar__wrap',
26
+ '.el-scrollbar__view',
27
+ '.vxe-table--body-wrapper',
28
+ '.vxe-table--body-inner-wrapper',
29
+ '.vxe-table--main-wrapper'
30
+ ]
31
+
32
+ /** 内联 computed 样式时关注的属性(布局 + 常见视觉) */
33
+ const INLINE_STYLE_PROPS = [
34
+ 'display', 'flex', 'flex-direction', 'flex-wrap', 'flex-grow', 'flex-shrink', 'flex-basis',
35
+ 'align-items', 'align-self', 'align-content', 'justify-content', 'justify-self', 'gap', 'row-gap', 'column-gap',
36
+ 'grid-template-columns', 'grid-template-rows', 'grid-column', 'grid-row',
37
+ 'position', 'top', 'right', 'bottom', 'left', 'z-index', 'float', 'clear',
38
+ 'width', 'height', 'min-width', 'min-height', 'max-width', 'max-height',
39
+ 'margin', 'margin-top', 'margin-right', 'margin-bottom', 'margin-left',
40
+ 'padding', 'padding-top', 'padding-right', 'padding-bottom', 'padding-left',
41
+ 'box-sizing', 'overflow', 'overflow-x', 'overflow-y',
42
+ 'border', 'border-top', 'border-right', 'border-bottom', 'border-left',
43
+ 'border-radius', 'border-collapse', 'table-layout',
44
+ 'background', 'background-color', 'background-image', 'background-size', 'background-position',
45
+ 'color', 'font-size', 'font-weight', 'font-style', 'line-height', 'letter-spacing',
46
+ 'text-align', 'text-decoration', 'vertical-align', 'white-space', 'word-break', 'word-wrap',
47
+ 'opacity', 'visibility', 'box-shadow', 'transform'
48
+ ]
49
+
50
+ const SNAPSHOT_BASE_CSS = `
51
+ html, body {
52
+ margin: 0;
53
+ padding: 8px;
54
+ background: #fff;
55
+ -webkit-print-color-adjust: exact;
56
+ print-color-adjust: exact;
57
+ }
58
+
59
+ n20-page,
60
+ [class$="-wrap"],
61
+ .n20-page-content,
62
+ .page-content,
63
+ .action-parse-form-container {
64
+ height: auto !important;
65
+ max-height: none !important;
66
+ overflow: visible !important;
67
+ }
68
+
69
+ .el-dialog__wrapper,
70
+ .el-message,
71
+ .el-message-box__wrapper,
72
+ .el-notification,
73
+ .el-loading-mask,
74
+ .page-snapshot-btn,
75
+ .page-button-shadow {
76
+ display: none !important;
77
+ }
78
+ `
79
+
80
+ const SNAPSHOT_PDF_CSS = `
81
+ @page { size: A4 landscape; margin: 15mm; }
82
+ html, body { font-family: SimSun, "Songti SC", "Microsoft YaHei", serif; font-size: 10px; line-height: 1.4; }
83
+ td, th { white-space: normal !important; padding: 4px; font-size: 10px; vertical-align: top; }
84
+ `
85
+
86
+ function safeRun(fn, fallback, label) {
87
+ try {
88
+ return fn()
89
+ } catch (e) {
90
+ if (label) console.warn(`[exportPageHtml] ${label}`, e)
91
+ return fallback
92
+ }
93
+ }
94
+
95
+ function buildSnapshotFilename() {
96
+ const ts = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19)
97
+ return `${SNAPSHOT_FILENAME_PREFIX}-${ts}.html`
98
+ }
99
+
100
+ function shouldSkipElement(el) {
101
+ if (!el || el.nodeType !== Node.ELEMENT_NODE) return false
102
+ if (SKIP_TAGS.has(el.tagName.toLowerCase())) return true
103
+ return SKIP_CLASSES.some((cls) => el.classList?.contains(cls))
104
+ }
105
+
106
+ function clonePageNode(node) {
107
+ if (!node) return null
108
+
109
+ return safeRun(() => {
110
+ if (node.nodeType === Node.TEXT_NODE) return node.cloneNode(false)
111
+ if (node.nodeType !== Node.ELEMENT_NODE) return null
112
+ if (shouldSkipElement(node)) return null
113
+
114
+ const tag = node.tagName.toLowerCase()
115
+ const cloned = node.cloneNode(false)
116
+
117
+ Array.from(node.attributes || []).forEach((attr) => {
118
+ safeRun(() => cloned.setAttribute(attr.name, attr.value), undefined)
119
+ })
120
+
121
+ if (tag === 'input') {
122
+ cloned.setAttribute('value', node.value || '')
123
+ if (node.type === 'checkbox' || node.type === 'radio') {
124
+ if (node.checked) cloned.setAttribute('checked', 'checked')
125
+ else cloned.removeAttribute('checked')
126
+ }
127
+ }
128
+ if (tag === 'textarea') {
129
+ cloned.textContent = node.value || ''
130
+ }
131
+ if (tag === 'select') {
132
+ const idx = node.selectedIndex
133
+ Array.from(cloned.options || []).forEach((opt, i) => {
134
+ if (i === idx) opt.setAttribute('selected', 'selected')
135
+ else opt.removeAttribute('selected')
136
+ })
137
+ }
138
+
139
+ Array.from(node.childNodes).forEach((child) => {
140
+ const childClone = clonePageNode(child)
141
+ if (childClone) cloned.appendChild(childClone)
142
+ })
143
+ return cloned
144
+ }, null, '克隆节点失败')
145
+ }
146
+
147
+ function unwrapScrollContainers(root) {
148
+ if (!root?.querySelectorAll) return
149
+ SCROLL_UNWRAP_SELECTORS.forEach((sel) => {
150
+ root.querySelectorAll(sel).forEach((el) => {
151
+ el.style.maxHeight = 'none'
152
+ el.style.height = 'auto'
153
+ el.style.overflow = 'visible'
154
+ })
155
+ })
156
+ }
157
+
158
+ /**
159
+ * 删除页面锚点导航 DOM(N20-anchor 右侧/左侧导航栏)
160
+ * @param {HTMLElement} root
161
+ */
162
+ export function removeAnchorNav(root) {
163
+ if (!root?.querySelectorAll) return
164
+
165
+ root.querySelectorAll('.detpl-edit-form-container.flex-box').forEach((flexBox) => {
166
+ Array.from(flexBox.children).forEach((child) => {
167
+ if (child.classList.contains('flex-item')) return
168
+ if (child.querySelector('.n20-anchor2-nav')) {
169
+ child.remove()
170
+ }
171
+ })
172
+ flexBox.style?.removeProperty('padding-right')
173
+ })
174
+
175
+ root.querySelectorAll('.n20-anchor, .n20-anchor-left, .n20-anchor2-sidebar').forEach((el) => {
176
+ el.remove()
177
+ })
178
+
179
+ root.querySelectorAll('.n20-anchor2-nav').forEach((el) => {
180
+ if (!el.closest('.flex-item')) {
181
+ el.remove()
182
+ }
183
+ })
184
+ }
185
+
186
+ /** 底部固定提交/操作按钮组选择器 */
187
+ const BOTTOM_OPERATE_SELECTORS = [
188
+ '.page-button-shadow',
189
+ '.detpl-form-operate-box',
190
+ '.self-footer',
191
+ '.page-footer-shadow'
192
+ ]
193
+
194
+ /**
195
+ * 删除底部固定提交按钮组 DOM
196
+ * @param {HTMLElement} root
197
+ */
198
+ export function removeBottomOperateButtons(root) {
199
+ if (!root?.querySelectorAll) return
200
+
201
+ BOTTOM_OPERATE_SELECTORS.forEach((selector) => {
202
+ root.querySelectorAll(selector).forEach((el) => el.remove())
203
+ })
204
+ }
205
+
206
+ function mergeInlineStyle(el, extraCss) {
207
+ if (!extraCss) return
208
+ const prev = el.getAttribute('style') || ''
209
+ const merged = prev ? `${prev};${extraCss}` : extraCss
210
+ el.setAttribute('style', merged)
211
+ }
212
+
213
+ function buildComputedStyleText(el) {
214
+ const computed = window.getComputedStyle(el)
215
+ const chunks = []
216
+
217
+ INLINE_STYLE_PROPS.forEach((prop) => {
218
+ const val = computed.getPropertyValue(prop)
219
+ if (!val || val === 'initial' || val === 'auto' && prop !== 'height' && prop !== 'width') return
220
+ if (prop === 'background-image' && val === 'none') return
221
+ chunks.push(`${prop}:${val}`)
222
+ })
223
+ return chunks.join(';')
224
+ }
225
+
226
+ function inlineComputedStyles(sourceEl, cloneEl) {
227
+ if (!sourceEl || !cloneEl || sourceEl.nodeType !== Node.ELEMENT_NODE) return
228
+
229
+ mergeInlineStyle(cloneEl, buildComputedStyleText(sourceEl))
230
+
231
+ const srcChildren = sourceEl.children || []
232
+ const cloneChildren = cloneEl.children || []
233
+ const len = Math.min(srcChildren.length, cloneChildren.length)
234
+ for (let i = 0; i < len; i++) {
235
+ inlineComputedStyles(srcChildren[i], cloneChildren[i])
236
+ }
237
+ }
238
+
239
+ function normalizeCssChunk(text) {
240
+ return (text || '').replace(/\s+/g, ' ').trim()
241
+ }
242
+
243
+ function collectDocumentCss() {
244
+ const seen = new Set()
245
+ const rules = []
246
+ const processedStyleNodes = new Set()
247
+
248
+ const addRule = (cssText) => {
249
+ const chunk = normalizeCssChunk(cssText)
250
+ if (!chunk || seen.has(chunk)) return
251
+ seen.add(chunk)
252
+ rules.push(cssText.trim())
253
+ }
254
+
255
+ safeRun(() => {
256
+ Array.from(document.styleSheets || []).forEach((sheet) => {
257
+ safeRun(() => {
258
+ const sheetRules = sheet.cssRules || sheet.rules
259
+ if (sheet.ownerNode) processedStyleNodes.add(sheet.ownerNode)
260
+ if (!sheetRules) return
261
+ Array.from(sheetRules).forEach((rule) => {
262
+ safeRun(() => addRule(rule.cssText), undefined)
263
+ })
264
+ }, () => {
265
+ if (sheet.ownerNode?.tagName === 'STYLE') {
266
+ processedStyleNodes.add(sheet.ownerNode)
267
+ addRule(sheet.ownerNode.textContent || '')
268
+ }
269
+ })
270
+ })
271
+ }, undefined, '遍历样式表失败')
272
+
273
+ safeRun(() => {
274
+ document.querySelectorAll('style').forEach((el) => {
275
+ if (!processedStyleNodes.has(el)) addRule(el.textContent || '')
276
+ })
277
+ }, undefined, '收集 style 标签失败')
278
+
279
+ return rules.join('\n')
280
+ }
281
+
282
+ function captureRootCssVariables() {
283
+ return safeRun(() => {
284
+ const styles = getComputedStyle(document.documentElement)
285
+ const vars = []
286
+ for (let i = 0; i < styles.length; i++) {
287
+ const prop = styles[i]
288
+ if (prop.startsWith('--')) {
289
+ vars.push(`${prop}:${styles.getPropertyValue(prop)}`)
290
+ }
291
+ }
292
+ return vars.length ? `:root { ${vars.join(';')} }` : ''
293
+ }, '', '收集 CSS 变量失败')
294
+ }
295
+
296
+ function stripHeavyFontFaces(css) {
297
+ return (css || '').replace(/@font-face\s*\{[^}]*font-family\s*:\s*SUN[^}]*\}/gi, '')
298
+ }
299
+
300
+ function getAppStaticBase() {
301
+ const base = typeof process !== 'undefined' && process.env?.BASE_URL
302
+ ? process.env.BASE_URL
303
+ : '/gdebit/'
304
+ try {
305
+ return new URL(`static/`, new URL(base, window.location.origin)).href
306
+ } catch (e) {
307
+ return `${window.location.origin}/gdebit/static/`
308
+ }
309
+ }
310
+
311
+ function getStylesheetBaseUrls() {
312
+ const bases = new Set([getAppStaticBase(), `${window.location.origin}/`])
313
+
314
+ safeRun(() => {
315
+ document.querySelectorAll('link[rel="stylesheet"][href]').forEach((link) => {
316
+ bases.add(new URL('.', link.href).href)
317
+ })
318
+ Array.from(document.styleSheets || []).forEach((sheet) => {
319
+ if (!sheet.href) return
320
+ bases.add(new URL('.', sheet.href).href)
321
+ })
322
+ }, undefined)
323
+
324
+ return [...bases]
325
+ }
326
+
327
+ function getLoadedFontResourceUrls() {
328
+ return safeRun(() => {
329
+ return performance
330
+ .getEntriesByType('resource')
331
+ .filter((entry) => /\.(woff2?|ttf|otf)(\?|$)/i.test(entry.name))
332
+ .map((entry) => entry.name)
333
+ }, [], '读取已加载字体资源失败')
334
+ }
335
+
336
+ function toAbsoluteFontAssetUrl(assetUrl) {
337
+ if (!assetUrl) return ''
338
+ if (/^https?:\/\//i.test(assetUrl) || assetUrl.startsWith('data:')) return assetUrl
339
+ try {
340
+ return new URL(assetUrl, window.location.origin).href
341
+ } catch (e) {
342
+ return assetUrl
343
+ }
344
+ }
345
+
346
+ function safeRequireFont(modulePath) {
347
+ try {
348
+ return toAbsoluteFontAssetUrl(require(modulePath))
349
+ } catch (e) {
350
+ return ''
351
+ }
352
+ }
353
+
354
+ /** webpack 解析的图标字体 URL(/gdebit/static/fonts/...) */
355
+ function getWebpackIconFontUrls() {
356
+ const paths = [
357
+ 'element-ui/lib/theme-chalk/fonts/element-icons.woff',
358
+ 'element-ui/lib/theme-chalk/fonts/element-icons.ttf',
359
+ 'n20-common-lib/src/assets/iconFont2/iconfont.woff2',
360
+ 'n20-common-lib/src/assets/iconFont2/iconfont.woff',
361
+ 'n20-common-lib/src/assets/iconFont2/iconfont.ttf'
362
+ ]
363
+
364
+ return [...new Set(paths.map(safeRequireFont).filter(Boolean))]
365
+ }
366
+
367
+ const ICON_FONT_FAMILIES = [
368
+ 'element-icons',
369
+ 'core-lib-iconfont',
370
+ 'iconfont',
371
+ 'vxeiconfont'
372
+ ]
373
+
374
+ function escapeRegExp(text) {
375
+ return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
376
+ }
377
+
378
+ /** 导出前触发图标字体加载,便于 performance / document.fonts 拿到真实 URL */
379
+ async function ensureIconFontsLoaded() {
380
+ const probe = document.createElement('div')
381
+ probe.style.cssText = 'position:absolute;left:-9999px;visibility:hidden;pointer-events:none'
382
+ probe.innerHTML = [
383
+ '<i class="el-icon-arrow-down"></i>',
384
+ '<i class="el-icon-date"></i>',
385
+ '<i class="n20-icon-yuefenqiehuan-zuoce"></i>',
386
+ '<i class="vxe-icon-caret-down"></i>'
387
+ ].join('')
388
+
389
+ document.body.appendChild(probe)
390
+
391
+ await safeRun(async () => {
392
+ if (!document.fonts?.load) return
393
+ const tasks = ICON_FONT_FAMILIES.map((family) => (
394
+ document.fonts.load(`16px ${family}`).catch(() => {})
395
+ ))
396
+ await Promise.all(tasks)
397
+ await document.fonts.ready
398
+ }, undefined)
399
+
400
+ await new Promise((resolve) => setTimeout(resolve, 120))
401
+ document.body.removeChild(probe)
402
+ }
403
+
404
+ function collectFontUrlsFromStyleSheets() {
405
+ const urls = new Set()
406
+
407
+ safeRun(() => {
408
+ Array.from(document.styleSheets || []).forEach((sheet) => {
409
+ let rules
410
+ try {
411
+ rules = sheet.cssRules
412
+ } catch (e) {
413
+ return
414
+ }
415
+ if (!rules) return
416
+
417
+ const base = sheet.href || window.location.href
418
+ Array.from(rules).forEach((rule) => {
419
+ if (rule.type !== CSSRule.FONT_FACE_RULE) return
420
+ const cssText = rule.cssText || ''
421
+ if (!/iconfont|element-icons|vxeicon/i.test(cssText)) return
422
+ for (const match of cssText.matchAll(/url\(\s*["']?([^"')]+)["']?\s*\)/g)) {
423
+ const raw = match[1].trim()
424
+ if (raw.startsWith('data:')) {
425
+ urls.add(raw)
426
+ continue
427
+ }
428
+ safeRun(() => urls.add(new URL(raw, base).href))
429
+ }
430
+ })
431
+ })
432
+ }, undefined)
433
+
434
+ return [...urls]
435
+ }
436
+
437
+ function getAllIconFontFetchUrls() {
438
+ const urls = new Set()
439
+
440
+ getWebpackIconFontUrls().forEach((url) => urls.add(url))
441
+ getLoadedFontResourceUrls().forEach((url) => {
442
+ if (/iconfont|element-icons|vxeicon|\.woff2?|\.ttf|\.otf/i.test(url)) {
443
+ urls.add(url)
444
+ }
445
+ })
446
+ collectFontUrlsFromStyleSheets().forEach((url) => urls.add(url))
447
+
448
+ return [...urls]
449
+ }
450
+
451
+ function getFilename(url) {
452
+ return (url || '').split('/').pop().split('?')[0]
453
+ }
454
+
455
+ function buildFontFetchCandidates(rawUrl) {
456
+ if (!rawUrl || rawUrl.startsWith('data:') || rawUrl.startsWith('blob:')) {
457
+ return rawUrl ? [rawUrl] : []
458
+ }
459
+
460
+ const candidates = new Set()
461
+ const filename = getFilename(rawUrl)
462
+
463
+ if (/^https?:\/\//i.test(rawUrl)) {
464
+ candidates.add(rawUrl)
465
+ }
466
+
467
+ safeRun(() => candidates.add(new URL(rawUrl, window.location.href).href))
468
+ getStylesheetBaseUrls().forEach((base) => {
469
+ safeRun(() => candidates.add(new URL(rawUrl, base).href))
470
+ })
471
+
472
+ getAllIconFontFetchUrls().forEach((knownUrl) => {
473
+ if (knownUrl.includes(filename)) {
474
+ candidates.add(knownUrl)
475
+ }
476
+ })
477
+
478
+ return [...candidates]
479
+ }
480
+
481
+ function isFontBinary(buffer) {
482
+ if (!buffer || buffer.byteLength < 4) return false
483
+ const bytes = new Uint8Array(buffer.slice(0, 4))
484
+ const signature = String.fromCharCode(bytes[0], bytes[1], bytes[2], bytes[3])
485
+ return (
486
+ signature === 'wOFF'
487
+ || signature === 'wOF2'
488
+ || signature === 'OTTO'
489
+ || (bytes[0] === 0x00 && bytes[1] === 0x01 && bytes[2] === 0x00 && bytes[3] === 0x00)
490
+ )
491
+ }
492
+
493
+ function isFontDataUrl(dataUrl) {
494
+ if (!dataUrl || typeof dataUrl !== 'string') return false
495
+ if (!dataUrl.startsWith('data:')) return false
496
+ if (/^data:text\/html/i.test(dataUrl)) return false
497
+ if (/^data:application\/json/i.test(dataUrl)) return false
498
+ if (/^data:image\//i.test(dataUrl)) return false
499
+ return /^data:font\//i.test(dataUrl) || /^data:application\//i.test(dataUrl)
500
+ }
501
+
502
+ async function fetchFontAsDataUrl(rawUrl) {
503
+ const candidates = buildFontFetchCandidates(rawUrl)
504
+
505
+ for (const candidate of candidates) {
506
+ if (candidate.startsWith('data:')) {
507
+ if (isFontDataUrl(candidate)) return candidate
508
+ continue
509
+ }
510
+
511
+ try {
512
+ const resp = await fetch(candidate)
513
+ if (!resp.ok) continue
514
+
515
+ const contentType = resp.headers.get('content-type') || ''
516
+ if (/text\/html|application\/json/i.test(contentType)) continue
517
+
518
+ const buffer = await resp.arrayBuffer()
519
+ if (!isFontBinary(buffer)) continue
520
+
521
+ const blob = new Blob([buffer], {
522
+ type: contentType || 'application/octet-stream'
523
+ })
524
+ const dataUrl = await new Promise((resolve, reject) => {
525
+ const reader = new FileReader()
526
+ reader.onload = () => resolve(reader.result)
527
+ reader.onerror = reject
528
+ reader.readAsDataURL(blob)
529
+ })
530
+
531
+ if (isFontDataUrl(dataUrl)) return dataUrl
532
+ } catch (e) {
533
+ // try next candidate
534
+ }
535
+ }
536
+
537
+ throw new Error(`font fetch failed: ${rawUrl}`)
538
+ }
539
+
540
+ function shouldEmbedFontUrl(url) {
541
+ if (!url || url.startsWith('data:')) return false
542
+ if (/SIMSUN|simsun/i.test(url)) return false
543
+ return /iconfont|element-icons|vxeicon|\.woff2?|\.ttf|\.otf/i.test(url)
544
+ }
545
+
546
+ async function embedIconFonts(css) {
547
+ await ensureIconFontsLoaded()
548
+
549
+ const filenameToData = new Map()
550
+
551
+ for (const knownUrl of getAllIconFontFetchUrls()) {
552
+ const filename = getFilename(knownUrl)
553
+ if (!filename || filenameToData.has(filename)) continue
554
+ if (knownUrl.startsWith('data:') && isFontDataUrl(knownUrl)) {
555
+ filenameToData.set(filename, knownUrl)
556
+ continue
557
+ }
558
+ try {
559
+ filenameToData.set(filename, await fetchFontAsDataUrl(knownUrl))
560
+ } catch (e) {
561
+ // try next source
562
+ }
563
+ }
564
+
565
+ const rawUrls = [...new Set(
566
+ [...(css || '').matchAll(/url\(\s*["']?([^"')]+)["']?\s*\)/g)].map((m) => m[1].trim())
567
+ )]
568
+
569
+ for (const rawUrl of rawUrls) {
570
+ if (!shouldEmbedFontUrl(rawUrl)) continue
571
+ const filename = getFilename(rawUrl)
572
+ if (filenameToData.has(filename)) continue
573
+ try {
574
+ filenameToData.set(filename, await fetchFontAsDataUrl(rawUrl))
575
+ } catch (e) {
576
+ console.warn('[exportPageHtml] 字体内嵌失败', rawUrl, e)
577
+ }
578
+ }
579
+
580
+ let result = css
581
+ filenameToData.forEach((dataUrl, filename) => {
582
+ const pattern = new RegExp(
583
+ `url\\(\\s*["']?[^"')]*${escapeRegExp(filename)}[^"')]*["']?\\s*\\)`,
584
+ 'gi'
585
+ )
586
+ result = result.replace(pattern, `url("${dataUrl}")`)
587
+ })
588
+
589
+ result = result.replace(
590
+ /url\(\s*["']?data:text\/html[^"')]+["']?\s*\)/gi,
591
+ 'url("")'
592
+ )
593
+
594
+ return result
595
+ }
596
+
597
+ function downloadBlob(blob, filename) {
598
+ safeRun(() => {
599
+ const url = URL.createObjectURL(blob)
600
+ const a = document.createElement('a')
601
+ a.href = url
602
+ a.download = filename
603
+ document.body.appendChild(a)
604
+ a.click()
605
+ document.body.removeChild(a)
606
+ URL.revokeObjectURL(url)
607
+ }, undefined, '下载失败')
608
+ }
609
+
610
+ function normalizeOptions(options) {
611
+ if (typeof options === 'boolean') {
612
+ return { autoDownload: options, inlineStyles: false, embedFonts: true, forPdf: false }
613
+ }
614
+ return {
615
+ autoDownload: false,
616
+ inlineStyles: false,
617
+ embedFonts: true,
618
+ forPdf: false,
619
+ ...(options || {})
620
+ }
621
+ }
622
+
623
+ /**
624
+ * 构建高保真页面 HTML 快照
625
+ * @param {HTMLElement} rootEl
626
+ * @param {{ autoDownload?: boolean, inlineStyles?: boolean, embedFonts?: boolean, forPdf?: boolean }} [options]
627
+ */
628
+ export async function buildPageHtmlSnapshot(rootEl, options = {}) {
629
+ const { inlineStyles, embedFonts, forPdf } = normalizeOptions(options)
630
+
631
+ if (!rootEl || rootEl.nodeType !== Node.ELEMENT_NODE) {
632
+ return { ok: false, message: '未找到页面内容' }
633
+ }
634
+
635
+ try {
636
+ if (embedFonts) {
637
+ await ensureIconFontsLoaded()
638
+ }
639
+
640
+ const filename = buildSnapshotFilename()
641
+ const pageTitle = document.title || SNAPSHOT_FILENAME_PREFIX
642
+ const ts = filename.replace(`${SNAPSHOT_FILENAME_PREFIX}-`, '').replace('.html', '')
643
+
644
+ const pageClone = clonePageNode(rootEl)
645
+ if (!pageClone) return { ok: false, message: '克隆页面失败' }
646
+
647
+ unwrapScrollContainers(pageClone)
648
+ removeAnchorNav(pageClone)
649
+ removeBottomOperateButtons(pageClone)
650
+
651
+ const pageHtml = pageClone.outerHTML
652
+ if (!pageHtml) return { ok: false, message: '生成页面 HTML 失败' }
653
+
654
+ let css = [
655
+ captureRootCssVariables(),
656
+ stripHeavyFontFaces(collectDocumentCss()),
657
+ SNAPSHOT_BASE_CSS,
658
+ forPdf ? SNAPSHOT_PDF_CSS : ''
659
+ ].filter(Boolean).join('\n')
660
+
661
+ if (embedFonts) {
662
+ css = await embedIconFonts(css)
663
+ }
664
+
665
+ const html = `<!DOCTYPE html>
666
+ <html lang="zh-CN">
667
+ <head>
668
+ <meta charset="UTF-8">
669
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
670
+ <title>${pageTitle} - ${ts}</title>
671
+ <style>
672
+ ${css}
673
+ </style>
674
+ </head>
675
+ <body>
676
+ ${pageHtml}
677
+ ${PAGE_SNAPSHOT_BACKEND_PLACEHOLDER}
678
+ </body>
679
+ </html>`
680
+
681
+ const blob = new Blob([html], { type: 'text/html;charset=utf-8' })
682
+ const file = new File([blob], filename, { type: blob.type })
683
+
684
+ return { ok: true, html, filename, blob, file, ts, pageTitle }
685
+ } catch (e) {
686
+ console.warn('[exportPageHtml] 构建页面快照失败', e)
687
+ return { ok: false, message: '构建页面快照失败' }
688
+ }
689
+ }
690
+
691
+ /**
692
+ * @param {HTMLElement} rootEl
693
+ * @param {boolean|object} [options]
694
+ */
695
+ export async function exportPageHtml(rootEl, options = false) {
696
+ const { autoDownload, ...rest } = normalizeOptions(options)
697
+ const result = await buildPageHtmlSnapshot(rootEl, rest)
698
+
699
+ if (!result.ok) return result
700
+ if (autoDownload) downloadBlob(result.blob, result.filename)
701
+ return result
702
+ }
703
+
704
+ /** @deprecated 兼容旧名,与 exportPageHtml 相同 */
705
+ export const exportPageSnapshot = exportPageHtml
706
+
707
+ /**
708
+ * 上传用 FormData
709
+ * @param {HTMLElement} rootEl
710
+ * @param {object} [options]
711
+ */
712
+ export async function createPageHtmlFormData(rootEl, options = {}) {
713
+ const built = await buildPageHtmlSnapshot(rootEl, options)
714
+ if (!built.ok) return built
715
+
716
+ return safeRun(() => {
717
+ const formData = new FormData()
718
+ formData.append('file', built.file, built.filename)
719
+ return {
720
+ ok: true,
721
+ formData,
722
+ file: built.file,
723
+ blob: built.blob,
724
+ filename: built.filename,
725
+ html: built.html
726
+ }
727
+ }, { ok: false, message: '构建上传数据失败' }, '构建 FormData 失败')
728
+ }