gcs-ui-lib 1.2.27 → 1.2.29

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,729 @@
1
+ /** 页面内「导出/上传快照」按钮统一 class,克隆时自动排除 */
2
+ export const PAGE_SNAPSHOT_BTN_CLASS = 'page-snapshot-btn'
3
+
4
+ /** 后端填充区域占位符,Java 侧按此字符串替换为审批进度等 HTML */
5
+ export const PAGE_SNAPSHOT_BACKEND_PLACEHOLDER = '<!-- PAGE_SNAPSHOT_BACKEND_PLACEHOLDER -->'
6
+
7
+ /** 页面底部操作栏,快照时自动排除 */
8
+ const PAGE_SNAPSHOT_SKIP_FOOTER_CLASS = 'page-button-shadow'
9
+
10
+ const SNAPSHOT_FILENAME_PREFIX = 'page-snapshot'
11
+ const SNAPSHOT_FILE_FIELD = 'file'
12
+
13
+ const DEFAULT_SKIP_TAGS = new Set(['script', 'style', 'link', 'noscript', 'svg'])
14
+ const DEFAULT_SKIP_CLASS_NAMES = [
15
+ PAGE_SNAPSHOT_BTN_CLASS,
16
+ PAGE_SNAPSHOT_SKIP_FOOTER_CLASS
17
+ ]
18
+
19
+ /**
20
+ * 供后端 Java(Flying Saucer / iText)转 PDF 时使用的内置样式。
21
+ * 放在收集到的业务 CSS 之后,优先级更高。
22
+ */
23
+ const SNAPSHOT_PDF_CSS = `
24
+ @page {
25
+ size: A4 landscape;
26
+ margin: 15mm;
27
+ }
28
+
29
+ html, body {
30
+ margin: 0;
31
+ padding: 0;
32
+ background: #fff;
33
+ font-family: SimSun, "Songti SC", "Microsoft YaHei", serif;
34
+ font-size: 10px;
35
+ line-height: 1.4;
36
+ -webkit-print-color-adjust: exact;
37
+ print-color-adjust: exact;
38
+ }
39
+
40
+ * { box-sizing: border-box; }
41
+
42
+ /* 页面根容器:展开高度,避免只导出可视区域 */
43
+ n20-page,
44
+ [class$="-wrap"],
45
+ .n20-page-content,
46
+ .page-content {
47
+ height: auto !important;
48
+ max-height: none !important;
49
+ overflow: visible !important;
50
+ }
51
+
52
+ /* 解除表格/滚动区裁剪(长列表被截断的常见原因) */
53
+ .el-table__body-wrapper,
54
+ .el-table__fixed-body-wrapper,
55
+ .el-scrollbar__wrap,
56
+ .el-scrollbar__view,
57
+ .vxe-table--body-wrapper,
58
+ .vxe-table--body-inner-wrapper,
59
+ .vxe-table--main-wrapper {
60
+ max-height: none !important;
61
+ height: auto !important;
62
+ overflow: visible !important;
63
+ }
64
+
65
+ .el-table,
66
+ .vxe-table {
67
+ width: 100% !important;
68
+ page-break-inside: auto;
69
+ }
70
+
71
+ /* 原生 table:分页与表头重复 */
72
+ table {
73
+ width: 100%;
74
+ border-collapse: collapse;
75
+ page-break-inside: auto;
76
+ }
77
+
78
+ thead {
79
+ display: table-header-group;
80
+ }
81
+
82
+ tfoot {
83
+ display: table-footer-group;
84
+ }
85
+
86
+ tbody {
87
+ display: table-row-group;
88
+ }
89
+
90
+ tr {
91
+ page-break-inside: avoid;
92
+ page-break-after: auto;
93
+ }
94
+
95
+ td, th {
96
+ max-width: none;
97
+ word-wrap: break-word;
98
+ word-break: break-word;
99
+ overflow-wrap: break-word;
100
+ white-space: normal !important;
101
+ padding: 4px;
102
+ font-size: 10px;
103
+ vertical-align: top;
104
+ }
105
+
106
+ /* Element 表头/行 */
107
+ .el-table__header-wrapper thead {
108
+ display: table-header-group;
109
+ }
110
+
111
+ .el-table__body tr {
112
+ page-break-inside: avoid;
113
+ }
114
+
115
+ /* vxe-table 行块级分页 */
116
+ .vxe-body--row {
117
+ page-break-inside: avoid;
118
+ }
119
+
120
+ /* 表单分组:尽量避免拦腰切断 */
121
+ .el-form-item,
122
+ .el-row,
123
+ .form-group {
124
+ page-break-inside: avoid;
125
+ }
126
+
127
+ /* 横向过长时略缩小(Flying Saucer 对 zoom 支持有限,以字号为主) */
128
+ .el-table th,
129
+ .el-table td,
130
+ .vxe-cell {
131
+ font-size: 10px;
132
+ }
133
+
134
+ /* 快照不需要的 UI */
135
+ .el-dialog__wrapper,
136
+ .el-message,
137
+ .el-message-box__wrapper,
138
+ .el-notification,
139
+ .el-loading-mask,
140
+ .page-snapshot-btn,
141
+ .page-button-shadow {
142
+ display: none !important;
143
+ }
144
+ `
145
+
146
+ function buildSnapshotFilename() {
147
+ try {
148
+ const ts = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19)
149
+ return `${SNAPSHOT_FILENAME_PREFIX}-${ts}.html`
150
+ } catch (e) {
151
+ return `${SNAPSHOT_FILENAME_PREFIX}.html`
152
+ }
153
+ }
154
+
155
+ /** 安全执行,异常时返回 fallback,不中断主流程 */
156
+ function safeRun(fn, fallback, label) {
157
+ try {
158
+ return fn()
159
+ } catch (e) {
160
+ if (label) {
161
+ console.warn(`[exportPageSnapshot] ${label}`, e)
162
+ }
163
+ return fallback
164
+ }
165
+ }
166
+
167
+ function createEmptyHtmlUsage() {
168
+ return { classes: new Set(), ids: new Set(), tags: new Set(), attrs: new Set() }
169
+ }
170
+
171
+ function removeTempStyleNode(node) {
172
+ try {
173
+ if (node?.parentNode) node.parentNode.removeChild(node)
174
+ } catch (e) {
175
+ // ignore
176
+ }
177
+ }
178
+
179
+ function shouldSkipElement(el, skipTags, skipClassNames) {
180
+ if (!el || el.nodeType !== Node.ELEMENT_NODE) return false
181
+ const tag = el.tagName.toLowerCase()
182
+ if (skipTags.has(tag)) return true
183
+ return skipClassNames.some((cls) => el.classList?.contains(cls))
184
+ }
185
+
186
+ function clonePageNode(node, skipTags, skipClassNames) {
187
+ if (!node) return null
188
+
189
+ try {
190
+ if (node.nodeType === Node.TEXT_NODE) return node.cloneNode(false)
191
+ if (node.nodeType !== Node.ELEMENT_NODE) return null
192
+ if (shouldSkipElement(node, skipTags, skipClassNames)) return null
193
+
194
+ const tag = node.tagName.toLowerCase()
195
+ const cloned = node.cloneNode(false)
196
+ Array.from(node.attributes || []).forEach((attr) => {
197
+ try {
198
+ cloned.setAttribute(attr.name, attr.value)
199
+ } catch (e) {
200
+ // ignore single attr failure
201
+ }
202
+ })
203
+
204
+ if (tag === 'input') {
205
+ cloned.setAttribute('value', node.value || '')
206
+ if (node.type === 'checkbox' || node.type === 'radio') {
207
+ if (node.checked) cloned.setAttribute('checked', 'checked')
208
+ else cloned.removeAttribute('checked')
209
+ }
210
+ }
211
+ if (tag === 'textarea') {
212
+ cloned.textContent = node.value || ''
213
+ }
214
+ if (tag === 'select') {
215
+ const idx = node.selectedIndex
216
+ Array.from(cloned.options || []).forEach((opt, i) => {
217
+ if (i === idx) opt.setAttribute('selected', 'selected')
218
+ else opt.removeAttribute('selected')
219
+ })
220
+ }
221
+
222
+ Array.from(node.childNodes).forEach((child) => {
223
+ const childClone = safeRun(
224
+ () => clonePageNode(child, skipTags, skipClassNames),
225
+ null,
226
+ '克隆子节点失败'
227
+ )
228
+ if (childClone) cloned.appendChild(childClone)
229
+ })
230
+ return cloned
231
+ } catch (e) {
232
+ console.warn('[exportPageSnapshot] 克隆节点失败', e)
233
+ return null
234
+ }
235
+ }
236
+
237
+ function normalizeCssChunk(text) {
238
+ return (text || '').replace(/\s+/g, ' ').trim()
239
+ }
240
+
241
+ const SKIP_SELECTOR_TAGS = new Set(['from', 'to'])
242
+
243
+ /** el-timeline 家族:el-timeline、el-timeline-item、el-timeline-item__tail 等所有 el-timeline 开头 class */
244
+ const EL_TIMELINE_CLASS_PREFIX = 'el-timeline'
245
+
246
+ /**
247
+ * 后端占位符替换后会注入审批进度等 HTML,裁剪时需强制保留相关 class 的 CSS。
248
+ * 匹配规则:选择器中任一 class 命中即保留整条规则。
249
+ */
250
+ const BACKEND_PLACEHOLDER_PRESERVE_CLASS_PATTERNS = [
251
+ /^n20-form-expandable-pane$/,
252
+ /^n20-approve/,
253
+ /^n20-title/,
254
+ /^n20-tips$/,
255
+ /^n20-time$/,
256
+ /^n20-worker$/,
257
+ /^n20-description/,
258
+ /^n20-icon-/,
259
+ /^result-left-name$/,
260
+ /^worker-icon$/,
261
+ /^color-primary$/,
262
+ /^color-0$/,
263
+ /^expand$/,
264
+ /^is-reverse$/
265
+ ]
266
+
267
+ function isElTimelineFamilyClass(className) {
268
+ return typeof className === 'string' && className.startsWith(EL_TIMELINE_CLASS_PREFIX)
269
+ }
270
+
271
+ function isPreservedPlaceholderClass(className) {
272
+ if (!className || typeof className !== 'string') return false
273
+ if (isElTimelineFamilyClass(className)) return true
274
+ return BACKEND_PLACEHOLDER_PRESERVE_CLASS_PATTERNS.some((p) => {
275
+ try {
276
+ return p.test(className)
277
+ } catch (e) {
278
+ return false
279
+ }
280
+ })
281
+ }
282
+
283
+ function selectorHasPreservedClass(selectorText) {
284
+ if (!selectorText || typeof selectorText !== 'string') return false
285
+
286
+ return safeRun(() => {
287
+ if (new RegExp(`\\.${EL_TIMELINE_CLASS_PREFIX}[\\w-]*`).test(selectorText)) return true
288
+
289
+ return selectorText.split(',').some((sel) => {
290
+ const noPseudo = sel
291
+ .replace(/:not\([^)]*\)/g, '')
292
+ .replace(/::?[\w-]+(\([^)]*\))?/g, '')
293
+ const { classes } = extractSimpleTokens(noPseudo)
294
+ return classes.some(isPreservedPlaceholderClass)
295
+ })
296
+ }, false, '保留 class 判断失败')
297
+ }
298
+
299
+ /** 从快照 HTML 提取选择器可能引用到的标识 */
300
+ function collectHtmlUsage(html) {
301
+ const usage = createEmptyHtmlUsage()
302
+ if (!html || typeof html !== 'string') return usage
303
+
304
+ return safeRun(() => {
305
+ for (const m of html.matchAll(/\bclass="([^"]*)"/g)) {
306
+ m[1].split(/\s+/).filter(Boolean).forEach((c) => usage.classes.add(c))
307
+ }
308
+ for (const m of html.matchAll(/\bid="([^"]+)"/g)) {
309
+ usage.ids.add(m[1])
310
+ }
311
+ for (const m of html.matchAll(/<([a-zA-Z][\w-]*)/g)) {
312
+ usage.tags.add(m[1].toLowerCase())
313
+ }
314
+ for (const m of html.matchAll(/\s(data-v-[a-f0-9]+)/g)) {
315
+ usage.attrs.add(m[1])
316
+ }
317
+ return usage
318
+ }, usage, '解析 HTML 使用信息失败')
319
+ }
320
+
321
+ function extractSimpleTokens(selector) {
322
+ const empty = { classes: [], ids: [], tags: [] }
323
+ if (!selector || typeof selector !== 'string') return empty
324
+
325
+ return safeRun(() => {
326
+ const classes = []
327
+ const ids = []
328
+ const tagList = []
329
+ const segments = selector.split(/[\s>+~]+/).filter(Boolean)
330
+
331
+ segments.forEach((seg) => {
332
+ let rest = seg
333
+ const tagMatch = rest.match(/^([a-zA-Z][\w-]*)/)
334
+ if (tagMatch && !/^[\.\#\[]/.test(rest)) {
335
+ const tag = tagMatch[1].toLowerCase()
336
+ if (!SKIP_SELECTOR_TAGS.has(tag)) tagList.push(tag)
337
+ rest = rest.slice(tagMatch[0].length)
338
+ }
339
+ for (const m of rest.matchAll(/\.([\w-]+)/g)) classes.push(m[1])
340
+ for (const m of rest.matchAll(/#([\w-]+)/g)) ids.push(m[1])
341
+ })
342
+
343
+ return { classes, ids, tags: tagList }
344
+ }, empty, '解析选择器失败')
345
+ }
346
+
347
+ function matchAttributeSelectors(selector, usage) {
348
+ if (!selector || !usage) return true
349
+
350
+ return safeRun(() => {
351
+ const attrRe = /\[([^\]]+)\]/g
352
+ let m = attrRe.exec(selector)
353
+ while (m) {
354
+ const content = m[1].trim()
355
+ const fullMatch = content.match(/^([\w-]+)\s*([*^$]?=)\s*["']?([^"']+)["']?$/)
356
+ if (fullMatch) {
357
+ const [, attr, op, val] = fullMatch
358
+ if (attr === 'class') {
359
+ const matched = op === '$='
360
+ ? [...usage.classes].some((c) => c.endsWith(val))
361
+ : op === '^='
362
+ ? [...usage.classes].some((c) => c.startsWith(val))
363
+ : op === '*='
364
+ ? [...usage.classes].some((c) => c.includes(val))
365
+ : usage.classes.has(val)
366
+ if (!matched) return false
367
+ } else if (!usage.attrs.has(attr)) {
368
+ return false
369
+ }
370
+ } else if (usage.attrs.has(content)) {
371
+ // [data-v-xxx] 等精确属性
372
+ } else if (!/^class/.test(content)) {
373
+ return false
374
+ }
375
+ m = attrRe.exec(selector)
376
+ }
377
+ return true
378
+ }, true, '属性选择器匹配失败')
379
+ }
380
+
381
+ function selectorMatchesUsage(selector, usage) {
382
+ if (!selector || typeof selector !== 'string' || !usage) return false
383
+
384
+ return safeRun(() => {
385
+ const s = selector.trim()
386
+ if (!s) return false
387
+ if (/^(\*|html|body)(?![\w-])/.test(s)) return true
388
+
389
+ if (!matchAttributeSelectors(s, usage)) return false
390
+
391
+ const noPseudo = s
392
+ .replace(/:not\([^)]*\)/g, '')
393
+ .replace(/::?[\w-]+(\([^)]*\))?/g, '')
394
+
395
+ const { classes, ids, tags } = extractSimpleTokens(noPseudo)
396
+ const hasAttrOnly = /\[[^\]]+\]/.test(s) && !classes.length && !ids.length && !tags.length
397
+
398
+ if (!classes.length && !ids.length && !tags.length) {
399
+ return hasAttrOnly
400
+ }
401
+
402
+ if (classes.some((c) => !usage.classes.has(c))) return false
403
+ if (ids.some((id) => !usage.ids.has(id))) return false
404
+ if (tags.length && !tags.every((t) => usage.tags.has(t))) return false
405
+
406
+ return true
407
+ }, false, '选择器匹配失败')
408
+ }
409
+
410
+ function selectorListMatches(selectorText, usage) {
411
+ if (!selectorText || typeof selectorText !== 'string') return false
412
+
413
+ return safeRun(() => {
414
+ if (selectorHasPreservedClass(selectorText)) return true
415
+ return selectorText.split(',').some((sel) => selectorMatchesUsage(sel, usage))
416
+ }, false, '选择器列表匹配失败')
417
+ }
418
+
419
+ function collectAnimationNamesFromStyle(style) {
420
+ const names = new Set()
421
+ if (!style) return names
422
+
423
+ return safeRun(() => {
424
+ const animName = style.animationName
425
+ if (animName && animName !== 'none') {
426
+ animName.split(',').forEach((n) => names.add(n.trim()))
427
+ }
428
+ const anim = style.animation
429
+ if (anim && anim !== 'none') {
430
+ const first = anim.trim().split(/\s+/)[0]
431
+ if (first && !/^[\d.]/.test(first)) names.add(first)
432
+ }
433
+ return names
434
+ }, names, '解析动画名失败')
435
+ }
436
+
437
+ function purgeCssRules(cssRules, usage, animationNames) {
438
+ const kept = []
439
+ const pendingKeyframes = []
440
+
441
+ Array.from(cssRules || []).forEach((rule) => {
442
+ try {
443
+ if (!rule) return
444
+
445
+ if (rule.type === CSSRule.KEYFRAMES_RULE) {
446
+ pendingKeyframes.push(rule)
447
+ return
448
+ }
449
+
450
+ if (rule.type === CSSRule.STYLE_RULE) {
451
+ if (!selectorListMatches(rule.selectorText, usage)) return
452
+ collectAnimationNamesFromStyle(rule.style).forEach((n) => animationNames.add(n))
453
+ if (rule.cssText) kept.push(rule.cssText)
454
+ return
455
+ }
456
+
457
+ if (rule.type === CSSRule.MEDIA_RULE || rule.type === CSSRule.SUPPORTS_RULE) {
458
+ const inner = purgeCssRules(rule.cssRules, usage, animationNames)
459
+ if (!inner.length) return
460
+ const prefix = rule.type === CSSRule.MEDIA_RULE ? '@media' : '@supports'
461
+ kept.push(`${prefix} ${rule.conditionText} {\n${inner.join('\n')}\n}`)
462
+ return
463
+ }
464
+
465
+ if (rule.type === CSSRule.FONT_FACE_RULE && rule.cssText) {
466
+ kept.push(rule.cssText)
467
+ }
468
+ } catch (e) {
469
+ // 单条规则解析失败时尽量保留原文,避免样式缺失
470
+ try {
471
+ if (rule?.cssText) kept.push(rule.cssText)
472
+ } catch (e2) {
473
+ console.warn('[exportPageSnapshot] CSS 规则处理失败', e)
474
+ }
475
+ }
476
+ })
477
+
478
+ pendingKeyframes.forEach((rule) => {
479
+ try {
480
+ if (rule?.name && animationNames.has(rule.name) && rule.cssText) {
481
+ kept.push(rule.cssText)
482
+ }
483
+ } catch (e) {
484
+ console.warn('[exportPageSnapshot] keyframes 规则处理失败', e)
485
+ }
486
+ })
487
+
488
+ return kept
489
+ }
490
+
491
+ /** 仅保留 HTML 中实际用到的 CSS 规则;失败时回退原始 CSS */
492
+ function purgeUnusedCss(cssText, html) {
493
+ if (!cssText) return ''
494
+ if (!html) return cssText
495
+
496
+ const usage = collectHtmlUsage(html)
497
+ const animationNames = new Set()
498
+ const node = document.createElement('style')
499
+ node.textContent = cssText
500
+
501
+ try {
502
+ document.head.appendChild(node)
503
+ } catch (e) {
504
+ console.warn('[exportPageSnapshot] 注入临时样式失败,跳过 CSS 裁剪', e)
505
+ return cssText
506
+ }
507
+
508
+ try {
509
+ const sheet = node.sheet
510
+ if (!sheet?.cssRules) return cssText
511
+ const purged = purgeCssRules(sheet.cssRules, usage, animationNames).join('\n')
512
+ return purged || cssText
513
+ } catch (e) {
514
+ console.warn('[exportPageSnapshot] CSS 裁剪失败,使用原始样式', e)
515
+ return cssText
516
+ } finally {
517
+ removeTempStyleNode(node)
518
+ }
519
+ }
520
+
521
+ /** 从可访问的 stylesheet 收集规则,按 cssText 去重 */
522
+ function collectUniquePageCss() {
523
+ const seen = new Set()
524
+ const rules = []
525
+ const processedStyleNodes = new Set()
526
+
527
+ const addRule = (cssText) => {
528
+ try {
529
+ const chunk = normalizeCssChunk(cssText)
530
+ if (!chunk || seen.has(chunk)) return
531
+ seen.add(chunk)
532
+ rules.push(cssText.trim())
533
+ } catch (e) {
534
+ // ignore single rule
535
+ }
536
+ }
537
+
538
+ safeRun(() => {
539
+ Array.from(document.styleSheets || []).forEach((sheet) => {
540
+ try {
541
+ const sheetRules = sheet.cssRules || sheet.rules
542
+ if (!sheetRules) return
543
+ if (sheet.ownerNode) processedStyleNodes.add(sheet.ownerNode)
544
+ Array.from(sheetRules).forEach((rule) => {
545
+ safeRun(() => addRule(rule.cssText), undefined, '收集 CSS 规则失败')
546
+ })
547
+ } catch (e) {
548
+ if (sheet.ownerNode && sheet.ownerNode.tagName === 'STYLE') {
549
+ processedStyleNodes.add(sheet.ownerNode)
550
+ addRule(sheet.ownerNode.textContent || '')
551
+ }
552
+ }
553
+ })
554
+ }, undefined, '遍历样式表失败')
555
+
556
+ safeRun(() => {
557
+ document.querySelectorAll('style').forEach((el) => {
558
+ if (!processedStyleNodes.has(el)) {
559
+ addRule(el.textContent || '')
560
+ }
561
+ })
562
+ }, undefined, '收集 style 标签失败')
563
+
564
+ return rules.join('\n')
565
+ }
566
+
567
+ function unwrapScrollContainers(root) {
568
+ if (!root?.querySelectorAll) return
569
+
570
+ const selectors = [
571
+ '.el-table__body-wrapper',
572
+ '.el-scrollbar__wrap',
573
+ '.vxe-table--body-wrapper'
574
+ ]
575
+ selectors.forEach((sel) => {
576
+ safeRun(() => {
577
+ root.querySelectorAll(sel).forEach((el) => {
578
+ el.style.maxHeight = 'none'
579
+ el.style.height = 'auto'
580
+ el.style.overflow = 'visible'
581
+ })
582
+ }, undefined, `展开滚动容器失败: ${sel}`)
583
+ })
584
+ }
585
+
586
+ /** HTML 底部占位区块,供后端注入审批进度等内容 */
587
+ function buildBackendPlaceholderSection() {
588
+ return `
589
+ ${PAGE_SNAPSHOT_BACKEND_PLACEHOLDER}
590
+ `
591
+ }
592
+
593
+ function downloadBlob(blob, filename) {
594
+ safeRun(() => {
595
+ const url = URL.createObjectURL(blob)
596
+ const a = document.createElement('a')
597
+ a.href = url
598
+ a.download = filename
599
+ document.body.appendChild(a)
600
+ a.click()
601
+ document.body.removeChild(a)
602
+ URL.revokeObjectURL(url)
603
+ }, undefined, '下载快照失败')
604
+ }
605
+
606
+ /**
607
+ * @param {string} pageHtml
608
+ * @param {{ fullCss?: boolean }} [options]
609
+ * @param {boolean} [options.fullCss=false] - true 时导出全量 CSS,false 时按 HTML 裁剪未使用规则
610
+ */
611
+ function resolvePageCss(pageHtml, { fullCss = false } = {}) {
612
+ const rawCss = collectUniquePageCss()
613
+ if (!pageHtml || fullCss) return rawCss
614
+
615
+ return safeRun(
616
+ () => purgeUnusedCss(rawCss, pageHtml),
617
+ rawCss,
618
+ 'CSS 裁剪失败,使用原始样式'
619
+ )
620
+ }
621
+
622
+ function normalizeSnapshotOptions(options) {
623
+ if (typeof options === 'boolean') {
624
+ return { autoDownload: options, fullCss: false }
625
+ }
626
+ return {
627
+ autoDownload: false,
628
+ fullCss: false,
629
+ ...(options || {})
630
+ }
631
+ }
632
+
633
+ /**
634
+ * 同步构建页面快照(HTML + Blob/File),供统一上传后端。
635
+ * @param {HTMLElement} rootEl - 页面根节点(如 n20-page 的 $el)
636
+ * @param {{ fullCss?: boolean }} [options]
637
+ * @param {boolean} [options.fullCss=false] - true 时导出全量 CSS,false 时按 HTML 裁剪未使用规则
638
+ */
639
+ export function buildPageSnapshot(rootEl, options = {}) {
640
+ const { fullCss } = normalizeSnapshotOptions(options)
641
+ if (!rootEl || rootEl.nodeType !== Node.ELEMENT_NODE) {
642
+ return { ok: false, message: '未找到页面内容' }
643
+ }
644
+
645
+ return safeRun(() => {
646
+ const filename = buildSnapshotFilename()
647
+ const pageTitle = document.title || SNAPSHOT_FILENAME_PREFIX
648
+ const ts = filename.replace(`${SNAPSHOT_FILENAME_PREFIX}-`, '').replace('.html', '')
649
+
650
+ const pageClone = clonePageNode(rootEl, DEFAULT_SKIP_TAGS, DEFAULT_SKIP_CLASS_NAMES)
651
+ if (!pageClone) {
652
+ return { ok: false, message: '克隆页面失败' }
653
+ }
654
+
655
+ unwrapScrollContainers(pageClone)
656
+
657
+ const pageHtml = safeRun(() => pageClone.outerHTML, '', '生成页面 HTML 失败')
658
+ if (!pageHtml) {
659
+ return { ok: false, message: '生成页面 HTML 失败' }
660
+ }
661
+
662
+ const css = resolvePageCss(pageHtml, { fullCss })
663
+
664
+ const html = `<!DOCTYPE html>
665
+ <html lang="zh-CN">
666
+ <head>
667
+ <meta charset="UTF-8">
668
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
669
+ <title>${pageTitle} - ${ts}</title>
670
+ <style>
671
+ ${css}
672
+ ${SNAPSHOT_PDF_CSS}
673
+ </style>
674
+ </head>
675
+ <body>
676
+ ${pageHtml}
677
+ ${buildBackendPlaceholderSection()}
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
+ }, { ok: false, message: '构建页面快照失败' }, '构建页面快照失败')
686
+ }
687
+
688
+ /**
689
+ * 构建上传用 FormData,字段名默认 file。
690
+ * @param {HTMLElement} rootEl
691
+ * @param {{ fullCss?: boolean }} [options]
692
+ * @param {boolean} [options.fullCss=false] - true 时导出全量 CSS,false 时按 HTML 裁剪未使用规则
693
+ */
694
+ export function createPageSnapshotFormData(rootEl, options = {}) {
695
+ const built = buildPageSnapshot(rootEl, options)
696
+ if (!built.ok) return built
697
+
698
+ return safeRun(() => {
699
+ const formData = new FormData()
700
+ formData.append(SNAPSHOT_FILE_FIELD, built.file, built.filename)
701
+
702
+ return {
703
+ ok: true,
704
+ formData,
705
+ file: built.file,
706
+ blob: built.blob,
707
+ filename: built.filename,
708
+ html: built.html
709
+ }
710
+ }, { ok: false, message: '构建上传数据失败' }, '构建 FormData 失败')
711
+ }
712
+
713
+ /**
714
+ * @param {HTMLElement} rootEl
715
+ * @param {boolean|{ autoDownload?: boolean, fullCss?: boolean }} [options=false]
716
+ * @param {boolean} [options.autoDownload=false] - true 时额外触发浏览器下载(调试用)
717
+ * @param {boolean} [options.fullCss=false] - true 时导出全量 CSS,false 时按 HTML 裁剪未使用规则
718
+ */
719
+ export function exportPageSnapshot(rootEl, options = false) {
720
+ const { autoDownload, fullCss } = normalizeSnapshotOptions(options)
721
+ const result = buildPageSnapshot(rootEl, { fullCss })
722
+ if (!result.ok) return result
723
+
724
+ if (autoDownload) {
725
+ downloadBlob(result.blob, result.filename)
726
+ }
727
+
728
+ return result
729
+ }