gcs-ui-lib 1.2.28 → 1.2.30
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/gcs-ui-lib.common.js +406 -185
- package/lib/gcs-ui-lib.css +2 -3
- package/lib/gcs-ui-lib.umd.js +406 -185
- package/lib/gcs-ui-lib.umd.min.js +11 -11
- package/package.json +1 -1
- package/src/utils/exportPageSnapshot.js +725 -0
|
@@ -0,0 +1,725 @@
|
|
|
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: 0;
|
|
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
|
+
/**
|
|
705
|
+
* 上传用 FormData
|
|
706
|
+
* @param {HTMLElement} rootEl
|
|
707
|
+
* @param {object} [options]
|
|
708
|
+
*/
|
|
709
|
+
export async function createPageHtmlFormData(rootEl, options = {}) {
|
|
710
|
+
const built = await buildPageHtmlSnapshot(rootEl, options)
|
|
711
|
+
if (!built.ok) return built
|
|
712
|
+
|
|
713
|
+
return safeRun(() => {
|
|
714
|
+
const formData = new FormData()
|
|
715
|
+
formData.append('file', built.file, built.filename)
|
|
716
|
+
return {
|
|
717
|
+
ok: true,
|
|
718
|
+
formData,
|
|
719
|
+
file: built.file,
|
|
720
|
+
blob: built.blob,
|
|
721
|
+
filename: built.filename,
|
|
722
|
+
html: built.html
|
|
723
|
+
}
|
|
724
|
+
}, { ok: false, message: '构建上传数据失败' }, '构建 FormData 失败')
|
|
725
|
+
}
|