gcs-ui-lib 1.2.28 → 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.
- package/lib/gcs-ui-lib.common.js +13654 -13433
- package/lib/gcs-ui-lib.css +2 -3
- package/lib/gcs-ui-lib.umd.js +13654 -13433
- package/lib/gcs-ui-lib.umd.min.js +102 -102
- package/package.json +1 -1
- package/src/utils/exportPageSnapshot.js +729 -0
|
@@ -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
|
+
}
|