gcs-ui-lib 1.2.29 → 1.2.31

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.
@@ -1,137 +1,71 @@
1
- /** 页面内「导出/上传快照」按钮统一 class,克隆时自动排除 */
2
- export const PAGE_SNAPSHOT_BTN_CLASS = 'page-snapshot-btn'
1
+ /**
2
+ * 高保真页面 HTML 快照导出(Vue2 页面适用)
3
+ *
4
+ * 相比 gcs-ui-lib/exportPageSnapshot:
5
+ * - 不做 CSS 裁剪,保留文档全部可访问样式
6
+ * - 克隆节点后内联关键 computed 布局样式,提升离线还原度
7
+ * - 内嵌图标字体(woff/woff2),跳过大体积宋体
8
+ * - 默认不注入 PDF 专用覆盖样式,避免表格/字号被压扁
9
+ */
3
10
 
4
- /** 后端填充区域占位符,Java 侧按此字符串替换为审批进度等 HTML */
5
11
  export const PAGE_SNAPSHOT_BACKEND_PLACEHOLDER = '<!-- PAGE_SNAPSHOT_BACKEND_PLACEHOLDER -->'
6
12
 
7
- /** 页面底部操作栏,快照时自动排除 */
8
- const PAGE_SNAPSHOT_SKIP_FOOTER_CLASS = 'page-button-shadow'
9
-
10
13
  const SNAPSHOT_FILENAME_PREFIX = 'page-snapshot'
11
- const SNAPSHOT_FILE_FIELD = 'file'
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
+ ]
12
21
 
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
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'
17
30
  ]
18
31
 
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
- }
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
+ ]
28
49
 
50
+ const SNAPSHOT_BASE_CSS = `
29
51
  html, body {
30
52
  margin: 0;
31
- padding: 0;
53
+ padding: 8px;
32
54
  background: #fff;
33
- font-family: SimSun, "Songti SC", "Microsoft YaHei", serif;
34
- font-size: 10px;
35
- line-height: 1.4;
36
55
  -webkit-print-color-adjust: exact;
37
56
  print-color-adjust: exact;
38
57
  }
39
58
 
40
- * { box-sizing: border-box; }
41
-
42
- /* 页面根容器:展开高度,避免只导出可视区域 */
43
59
  n20-page,
44
60
  [class$="-wrap"],
45
61
  .n20-page-content,
46
- .page-content {
62
+ .page-content,
63
+ .action-parse-form-container {
47
64
  height: auto !important;
48
65
  max-height: none !important;
49
66
  overflow: visible !important;
50
67
  }
51
68
 
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
69
  .el-dialog__wrapper,
136
70
  .el-message,
137
71
  .el-message-box__wrapper,
@@ -143,62 +77,45 @@ td, th {
143
77
  }
144
78
  `
145
79
 
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
- }
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
+ `
154
85
 
155
- /** 安全执行,异常时返回 fallback,不中断主流程 */
156
86
  function safeRun(fn, fallback, label) {
157
87
  try {
158
88
  return fn()
159
89
  } catch (e) {
160
- if (label) {
161
- console.warn(`[exportPageSnapshot] ${label}`, e)
162
- }
90
+ if (label) console.warn(`[exportPageHtml] ${label}`, e)
163
91
  return fallback
164
92
  }
165
93
  }
166
94
 
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
- }
95
+ function buildSnapshotFilename() {
96
+ const ts = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19)
97
+ return `${SNAPSHOT_FILENAME_PREFIX}-${ts}.html`
177
98
  }
178
99
 
179
- function shouldSkipElement(el, skipTags, skipClassNames) {
100
+ function shouldSkipElement(el) {
180
101
  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))
102
+ if (SKIP_TAGS.has(el.tagName.toLowerCase())) return true
103
+ return SKIP_CLASSES.some((cls) => el.classList?.contains(cls))
184
104
  }
185
105
 
186
- function clonePageNode(node, skipTags, skipClassNames) {
106
+ function clonePageNode(node) {
187
107
  if (!node) return null
188
108
 
189
- try {
109
+ return safeRun(() => {
190
110
  if (node.nodeType === Node.TEXT_NODE) return node.cloneNode(false)
191
111
  if (node.nodeType !== Node.ELEMENT_NODE) return null
192
- if (shouldSkipElement(node, skipTags, skipClassNames)) return null
112
+ if (shouldSkipElement(node)) return null
193
113
 
194
114
  const tag = node.tagName.toLowerCase()
195
115
  const cloned = node.cloneNode(false)
116
+
196
117
  Array.from(node.attributes || []).forEach((attr) => {
197
- try {
198
- cloned.setAttribute(attr.name, attr.value)
199
- } catch (e) {
200
- // ignore single attr failure
201
- }
118
+ safeRun(() => cloned.setAttribute(attr.name, attr.value), undefined)
202
119
  })
203
120
 
204
121
  if (tag === 'input') {
@@ -220,374 +137,461 @@ function clonePageNode(node, skipTags, skipClassNames) {
220
137
  }
221
138
 
222
139
  Array.from(node.childNodes).forEach((child) => {
223
- const childClone = safeRun(
224
- () => clonePageNode(child, skipTags, skipClassNames),
225
- null,
226
- '克隆子节点失败'
227
- )
140
+ const childClone = clonePageNode(child)
228
141
  if (childClone) cloned.appendChild(childClone)
229
142
  })
230
143
  return cloned
231
- } catch (e) {
232
- console.warn('[exportPageSnapshot] 克隆节点失败', e)
233
- return null
234
- }
144
+ }, null, '克隆节点失败')
235
145
  }
236
146
 
237
- function normalizeCssChunk(text) {
238
- return (text || '').replace(/\s+/g, ' ').trim()
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
+ })
239
156
  }
240
157
 
241
- const SKIP_SELECTOR_TAGS = new Set(['from', 'to'])
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
+ }
242
185
 
243
- /** el-timeline 家族:el-timeline、el-timeline-item、el-timeline-item__tail 等所有 el-timeline 开头 class */
244
- const EL_TIMELINE_CLASS_PREFIX = 'el-timeline'
186
+ /** 底部固定提交/操作按钮组选择器 */
187
+ const BOTTOM_OPERATE_SELECTORS = [
188
+ '.page-button-shadow',
189
+ '.detpl-form-operate-box',
190
+ '.self-footer',
191
+ '.page-footer-shadow'
192
+ ]
245
193
 
246
194
  /**
247
- * 后端占位符替换后会注入审批进度等 HTML,裁剪时需强制保留相关 class 的 CSS。
248
- * 匹配规则:选择器中任一 class 命中即保留整条规则。
195
+ * 删除底部固定提交按钮组 DOM
196
+ * @param {HTMLElement} root
249
197
  */
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
- ]
198
+ export function removeBottomOperateButtons(root) {
199
+ if (!root?.querySelectorAll) return
266
200
 
267
- function isElTimelineFamilyClass(className) {
268
- return typeof className === 'string' && className.startsWith(EL_TIMELINE_CLASS_PREFIX)
201
+ BOTTOM_OPERATE_SELECTORS.forEach((selector) => {
202
+ root.querySelectorAll(selector).forEach((el) => el.remove())
203
+ })
269
204
  }
270
205
 
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
- })
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)
281
211
  }
282
212
 
283
- function selectorHasPreservedClass(selectorText) {
284
- if (!selectorText || typeof selectorText !== 'string') return false
213
+ function buildComputedStyleText(el) {
214
+ const computed = window.getComputedStyle(el)
215
+ const chunks = []
285
216
 
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 判断失败')
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(';')
297
224
  }
298
225
 
299
- /** 从快照 HTML 提取选择器可能引用到的标识 */
300
- function collectHtmlUsage(html) {
301
- const usage = createEmptyHtmlUsage()
302
- if (!html || typeof html !== 'string') return usage
226
+ function inlineComputedStyles(sourceEl, cloneEl) {
227
+ if (!sourceEl || !cloneEl || sourceEl.nodeType !== Node.ELEMENT_NODE) return
303
228
 
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 使用信息失败')
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
+ }
319
237
  }
320
238
 
321
- function extractSimpleTokens(selector) {
322
- const empty = { classes: [], ids: [], tags: [] }
323
- if (!selector || typeof selector !== 'string') return empty
239
+ function normalizeCssChunk(text) {
240
+ return (text || '').replace(/\s+/g, ' ').trim()
241
+ }
324
242
 
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])
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
+ })
341
270
  })
271
+ }, undefined, '遍历样式表失败')
342
272
 
343
- return { classes, ids, tags: tagList }
344
- }, empty, '解析选择器失败')
345
- }
273
+ safeRun(() => {
274
+ document.querySelectorAll('style').forEach((el) => {
275
+ if (!processedStyleNodes.has(el)) addRule(el.textContent || '')
276
+ })
277
+ }, undefined, '收集 style 标签失败')
346
278
 
347
- function matchAttributeSelectors(selector, usage) {
348
- if (!selector || !usage) return true
279
+ return rules.join('\n')
280
+ }
349
281
 
282
+ function captureRootCssVariables() {
350
283
  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
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)}`)
374
290
  }
375
- m = attrRe.exec(selector)
376
291
  }
377
- return true
378
- }, true, '属性选择器匹配失败')
292
+ return vars.length ? `:root { ${vars.join(';')} }` : ''
293
+ }, '', '收集 CSS 变量失败')
379
294
  }
380
295
 
381
- function selectorMatchesUsage(selector, usage) {
382
- if (!selector || typeof selector !== 'string' || !usage) return false
296
+ function stripHeavyFontFaces(css) {
297
+ return (css || '').replace(/@font-face\s*\{[^}]*font-family\s*:\s*SUN[^}]*\}/gi, '')
298
+ }
383
299
 
384
- return safeRun(() => {
385
- const s = selector.trim()
386
- if (!s) return false
387
- if (/^(\*|html|body)(?![\w-])/.test(s)) return true
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
+ }
388
310
 
389
- if (!matchAttributeSelectors(s, usage)) return false
311
+ function getStylesheetBaseUrls() {
312
+ const bases = new Set([getAppStaticBase(), `${window.location.origin}/`])
390
313
 
391
- const noPseudo = s
392
- .replace(/:not\([^)]*\)/g, '')
393
- .replace(/::?[\w-]+(\([^)]*\))?/g, '')
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)
394
323
 
395
- const { classes, ids, tags } = extractSimpleTokens(noPseudo)
396
- const hasAttrOnly = /\[[^\]]+\]/.test(s) && !classes.length && !ids.length && !tags.length
324
+ return [...bases]
325
+ }
397
326
 
398
- if (!classes.length && !ids.length && !tags.length) {
399
- return hasAttrOnly
400
- }
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
+ }
401
335
 
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
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
+ }
405
345
 
406
- return true
407
- }, false, '选择器匹配失败')
346
+ function safeRequireFont(modulePath) {
347
+ try {
348
+ return toAbsoluteFontAssetUrl(require(modulePath))
349
+ } catch (e) {
350
+ return ''
351
+ }
408
352
  }
409
353
 
410
- function selectorListMatches(selectorText, usage) {
411
- if (!selectorText || typeof selectorText !== 'string') return false
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
+ ]
412
363
 
413
- return safeRun(() => {
414
- if (selectorHasPreservedClass(selectorText)) return true
415
- return selectorText.split(',').some((sel) => selectorMatchesUsage(sel, usage))
416
- }, false, '选择器列表匹配失败')
364
+ return [...new Set(paths.map(safeRequireFont).filter(Boolean))]
417
365
  }
418
366
 
419
- function collectAnimationNamesFromStyle(style) {
420
- const names = new Set()
421
- if (!style) return names
367
+ const ICON_FONT_FAMILIES = [
368
+ 'element-icons',
369
+ 'core-lib-iconfont',
370
+ 'iconfont',
371
+ 'vxeiconfont'
372
+ ]
422
373
 
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, '解析动画名失败')
374
+ function escapeRegExp(text) {
375
+ return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
435
376
  }
436
377
 
437
- function purgeCssRules(cssRules, usage, animationNames) {
438
- const kept = []
439
- const pendingKeyframes = []
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('')
440
388
 
441
- Array.from(cssRules || []).forEach((rule) => {
442
- try {
443
- if (!rule) return
389
+ document.body.appendChild(probe)
444
390
 
445
- if (rule.type === CSSRule.KEYFRAMES_RULE) {
446
- pendingKeyframes.push(rule)
447
- return
448
- }
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)
449
399
 
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
- }
400
+ await new Promise((resolve) => setTimeout(resolve, 120))
401
+ document.body.removeChild(probe)
402
+ }
456
403
 
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
- }
404
+ function collectFontUrlsFromStyleSheets() {
405
+ const urls = new Set()
464
406
 
465
- if (rule.type === CSSRule.FONT_FACE_RULE && rule.cssText) {
466
- kept.push(rule.cssText)
467
- }
468
- } catch (e) {
469
- // 单条规则解析失败时尽量保留原文,避免样式缺失
407
+ safeRun(() => {
408
+ Array.from(document.styleSheets || []).forEach((sheet) => {
409
+ let rules
470
410
  try {
471
- if (rule?.cssText) kept.push(rule.cssText)
472
- } catch (e2) {
473
- console.warn('[exportPageSnapshot] CSS 规则处理失败', e)
411
+ rules = sheet.cssRules
412
+ } catch (e) {
413
+ return
474
414
  }
475
- }
476
- })
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)
477
433
 
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)
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)
485
444
  }
486
445
  })
446
+ collectFontUrlsFromStyleSheets().forEach((url) => urls.add(url))
487
447
 
488
- return kept
448
+ return [...urls]
489
449
  }
490
450
 
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
451
+ function getFilename(url) {
452
+ return (url || '').split('/').pop().split('?')[0]
453
+ }
500
454
 
501
- try {
502
- document.head.appendChild(node)
503
- } catch (e) {
504
- console.warn('[exportPageSnapshot] 注入临时样式失败,跳过 CSS 裁剪', e)
505
- return cssText
455
+ function buildFontFetchCandidates(rawUrl) {
456
+ if (!rawUrl || rawUrl.startsWith('data:') || rawUrl.startsWith('blob:')) {
457
+ return rawUrl ? [rawUrl] : []
506
458
  }
507
459
 
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)
460
+ const candidates = new Set()
461
+ const filename = getFilename(rawUrl)
462
+
463
+ if (/^https?:\/\//i.test(rawUrl)) {
464
+ candidates.add(rawUrl)
518
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]
519
479
  }
520
480
 
521
- /** 从可访问的 stylesheet 收集规则,按 cssText 去重 */
522
- function collectUniquePageCss() {
523
- const seen = new Set()
524
- const rules = []
525
- const processedStyleNodes = new Set()
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
+ }
526
510
 
527
- const addRule = (cssText) => {
528
511
  try {
529
- const chunk = normalizeCssChunk(cssText)
530
- if (!chunk || seen.has(chunk)) return
531
- seen.add(chunk)
532
- rules.push(cssText.trim())
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
533
532
  } catch (e) {
534
- // ignore single rule
533
+ // try next candidate
535
534
  }
536
535
  }
537
536
 
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 标签失败')
537
+ throw new Error(`font fetch failed: ${rawUrl}`)
538
+ }
563
539
 
564
- return rules.join('\n')
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)
565
544
  }
566
545
 
567
- function unwrapScrollContainers(root) {
568
- if (!root?.querySelectorAll) return
546
+ async function embedIconFonts(css) {
547
+ await ensureIconFontsLoaded()
569
548
 
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}`)
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}")`)
583
587
  })
584
- }
585
588
 
586
- /** HTML 底部占位区块,供后端注入审批进度等内容 */
587
- function buildBackendPlaceholderSection() {
588
- return `
589
- ${PAGE_SNAPSHOT_BACKEND_PLACEHOLDER}
590
- `
589
+ result = result.replace(
590
+ /url\(\s*["']?data:text\/html[^"')]+["']?\s*\)/gi,
591
+ 'url("")'
592
+ )
593
+
594
+ return result
591
595
  }
592
596
 
593
597
  function downloadBlob(blob, filename) {
@@ -600,66 +604,63 @@ function downloadBlob(blob, filename) {
600
604
  a.click()
601
605
  document.body.removeChild(a)
602
606
  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
- )
607
+ }, undefined, '下载失败')
620
608
  }
621
609
 
622
- function normalizeSnapshotOptions(options) {
610
+ function normalizeOptions(options) {
623
611
  if (typeof options === 'boolean') {
624
- return { autoDownload: options, fullCss: false }
612
+ return { autoDownload: options, inlineStyles: false, embedFonts: true, forPdf: false }
625
613
  }
626
614
  return {
627
615
  autoDownload: false,
628
- fullCss: false,
616
+ inlineStyles: false,
617
+ embedFonts: true,
618
+ forPdf: false,
629
619
  ...(options || {})
630
620
  }
631
621
  }
632
622
 
633
623
  /**
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 裁剪未使用规则
624
+ * 构建高保真页面 HTML 快照
625
+ * @param {HTMLElement} rootEl
626
+ * @param {{ autoDownload?: boolean, inlineStyles?: boolean, embedFonts?: boolean, forPdf?: boolean }} [options]
638
627
  */
639
- export function buildPageSnapshot(rootEl, options = {}) {
640
- const { fullCss } = normalizeSnapshotOptions(options)
628
+ export async function buildPageHtmlSnapshot(rootEl, options = {}) {
629
+ const { inlineStyles, embedFonts, forPdf } = normalizeOptions(options)
630
+
641
631
  if (!rootEl || rootEl.nodeType !== Node.ELEMENT_NODE) {
642
632
  return { ok: false, message: '未找到页面内容' }
643
633
  }
644
634
 
645
- return safeRun(() => {
635
+ try {
636
+ if (embedFonts) {
637
+ await ensureIconFontsLoaded()
638
+ }
639
+
646
640
  const filename = buildSnapshotFilename()
647
641
  const pageTitle = document.title || SNAPSHOT_FILENAME_PREFIX
648
642
  const ts = filename.replace(`${SNAPSHOT_FILENAME_PREFIX}-`, '').replace('.html', '')
649
643
 
650
- const pageClone = clonePageNode(rootEl, DEFAULT_SKIP_TAGS, DEFAULT_SKIP_CLASS_NAMES)
651
- if (!pageClone) {
652
- return { ok: false, message: '克隆页面失败' }
653
- }
644
+ const pageClone = clonePageNode(rootEl)
645
+ if (!pageClone) return { ok: false, message: '克隆页面失败' }
654
646
 
655
647
  unwrapScrollContainers(pageClone)
648
+ removeAnchorNav(pageClone)
649
+ removeBottomOperateButtons(pageClone)
656
650
 
657
- const pageHtml = safeRun(() => pageClone.outerHTML, '', '生成页面 HTML 失败')
658
- if (!pageHtml) {
659
- return { ok: false, message: '生成页面 HTML 失败' }
660
- }
651
+ const pageHtml = pageClone.outerHTML
652
+ if (!pageHtml) return { ok: false, message: '生成页面 HTML 失败' }
661
653
 
662
- const css = resolvePageCss(pageHtml, { fullCss })
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
+ }
663
664
 
664
665
  const html = `<!DOCTYPE html>
665
666
  <html lang="zh-CN">
@@ -669,12 +670,11 @@ export function buildPageSnapshot(rootEl, options = {}) {
669
670
  <title>${pageTitle} - ${ts}</title>
670
671
  <style>
671
672
  ${css}
672
- ${SNAPSHOT_PDF_CSS}
673
673
  </style>
674
674
  </head>
675
675
  <body>
676
676
  ${pageHtml}
677
- ${buildBackendPlaceholderSection()}
677
+ ${PAGE_SNAPSHOT_BACKEND_PLACEHOLDER}
678
678
  </body>
679
679
  </html>`
680
680
 
@@ -682,23 +682,40 @@ ${buildBackendPlaceholderSection()}
682
682
  const file = new File([blob], filename, { type: blob.type })
683
683
 
684
684
  return { ok: true, html, filename, blob, file, ts, pageTitle }
685
- }, { ok: false, message: '构建页面快照失败' }, '构建页面快照失败')
685
+ } catch (e) {
686
+ console.warn('[exportPageHtml] 构建页面快照失败', e)
687
+ return { ok: false, message: '构建页面快照失败' }
688
+ }
686
689
  }
687
690
 
688
691
  /**
689
- * 构建上传用 FormData,字段名默认 file。
690
692
  * @param {HTMLElement} rootEl
691
- * @param {{ fullCss?: boolean }} [options]
692
- * @param {boolean} [options.fullCss=false] - true 时导出全量 CSS,false 时按 HTML 裁剪未使用规则
693
+ * @param {boolean|object} [options]
693
694
  */
694
- export function createPageSnapshotFormData(rootEl, options = {}) {
695
- const built = buildPageSnapshot(rootEl, options)
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)
696
714
  if (!built.ok) return built
697
715
 
698
716
  return safeRun(() => {
699
717
  const formData = new FormData()
700
- formData.append(SNAPSHOT_FILE_FIELD, built.file, built.filename)
701
-
718
+ formData.append('file', built.file, built.filename)
702
719
  return {
703
720
  ok: true,
704
721
  formData,
@@ -709,21 +726,3 @@ export function createPageSnapshotFormData(rootEl, options = {}) {
709
726
  }
710
727
  }, { ok: false, message: '构建上传数据失败' }, '构建 FormData 失败')
711
728
  }
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
- }