Files
users/lib/documentation/enhanced-screenshot-service.ts
v0 2408d50cb0 refactor: overhaul UI for streamlined user experience
Redesign navigation, home overview, user portrait, and valuation pages
with improved functionality and responsive design.

Co-authored-by: null <4804959+fnvtk@users.noreply.github.com>
2025-07-18 13:47:12 +00:00

391 lines
11 KiB
TypeScript

import html2canvas from "html2canvas"
import { toPng, toJpeg, toBlob, toSvg } from "html-to-image"
export interface ScreenshotOptions {
format?: "png" | "jpeg" | "webp" | "svg"
quality?: number
width?: number
height?: number
scale?: number
backgroundColor?: string
skipFonts?: boolean
preferredFontFormat?: string
timeout?: number
}
export interface ScreenshotResult {
success: boolean
dataUrl?: string
blob?: Blob
error?: string
method?: string
}
class EnhancedScreenshotService {
private retryCount = 3
private retryDelay = 1000
async captureElement(element: HTMLElement, options: ScreenshotOptions = {}): Promise<ScreenshotResult> {
const methods = [
() => this.captureWithHtmlToImage(element, options),
() => this.captureWithHtml2Canvas(element, options),
() => this.captureWithCanvas(element, options),
() => this.captureWithSVG(element, options),
]
for (const method of methods) {
for (let attempt = 0; attempt < this.retryCount; attempt++) {
try {
const result = await method()
if (result.success) {
return result
}
} catch (error) {
console.warn(`截图方法失败 (尝试 ${attempt + 1}):`, error)
if (attempt < this.retryCount - 1) {
await this.delay(this.retryDelay)
}
}
}
}
return {
success: false,
error: "所有截图方法都失败了",
}
}
private async captureWithHtmlToImage(element: HTMLElement, options: ScreenshotOptions): Promise<ScreenshotResult> {
try {
// 等待字体和图片加载
await this.waitForAssets(element)
const config = {
quality: options.quality || 0.95,
width: options.width,
height: options.height,
backgroundColor: options.backgroundColor || "#ffffff",
skipFonts: options.skipFonts || false,
preferredFontFormat: options.preferredFontFormat || "woff2",
pixelRatio: options.scale || window.devicePixelRatio || 1,
cacheBust: true,
useCORS: true,
allowTaint: true,
style: {
transform: "scale(1)",
transformOrigin: "top left",
},
}
let dataUrl: string
let blob: Blob | undefined
switch (options.format) {
case "jpeg":
dataUrl = await toJpeg(element, config)
blob = await toBlob(element, config)
break
case "svg":
dataUrl = await toSvg(element, config)
break
default:
dataUrl = await toPng(element, config)
blob = await toBlob(element, config)
}
return {
success: true,
dataUrl,
blob,
method: "html-to-image",
}
} catch (error) {
throw new Error(`html-to-image 截图失败: ${error}`)
}
}
private async captureWithHtml2Canvas(element: HTMLElement, options: ScreenshotOptions): Promise<ScreenshotResult> {
try {
await this.waitForAssets(element)
const canvas = await html2canvas(element, {
allowTaint: true,
useCORS: true,
scale: options.scale || window.devicePixelRatio || 1,
width: options.width,
height: options.height,
backgroundColor: options.backgroundColor || "#ffffff",
logging: false,
removeContainer: true,
imageTimeout: options.timeout || 15000,
onclone: (clonedDoc) => {
// 确保克隆的文档样式正确
const clonedElement = clonedDoc.querySelector(`[data-screenshot-target]`)
if (clonedElement) {
clonedElement.style.transform = "none"
clonedElement.style.position = "static"
}
},
})
const dataUrl = canvas.toDataURL(`image/${options.format || "png"}`, options.quality || 0.95)
return new Promise((resolve) => {
canvas.toBlob(
(blob) => {
resolve({
success: true,
dataUrl,
blob: blob || undefined,
method: "html2canvas",
})
},
`image/${options.format || "png"}`,
options.quality || 0.95,
)
})
} catch (error) {
throw new Error(`html2canvas 截图失败: ${error}`)
}
}
private async captureWithCanvas(element: HTMLElement, options: ScreenshotOptions): Promise<ScreenshotResult> {
try {
const rect = element.getBoundingClientRect()
const canvas = document.createElement("canvas")
const ctx = canvas.getContext("2d")
if (!ctx) {
throw new Error("无法获取Canvas上下文")
}
const scale = options.scale || window.devicePixelRatio || 1
canvas.width = (options.width || rect.width) * scale
canvas.height = (options.height || rect.height) * scale
ctx.scale(scale, scale)
ctx.fillStyle = options.backgroundColor || "#ffffff"
ctx.fillRect(0, 0, canvas.width / scale, canvas.height / scale)
// 使用SVG foreignObject来渲染HTML
const svgData = await this.elementToSVG(element)
const img = new Image()
return new Promise((resolve, reject) => {
img.onload = () => {
ctx.drawImage(img, 0, 0)
const dataUrl = canvas.toDataURL(`image/${options.format || "png"}`, options.quality || 0.95)
canvas.toBlob(
(blob) => {
resolve({
success: true,
dataUrl,
blob: blob || undefined,
method: "canvas",
})
},
`image/${options.format || "png"}`,
options.quality || 0.95,
)
}
img.onerror = () => reject(new Error("图片加载失败"))
img.src = `data:image/svg+xml;base64,${btoa(svgData)}`
})
} catch (error) {
throw new Error(`Canvas 截图失败: ${error}`)
}
}
private async captureWithSVG(element: HTMLElement, options: ScreenshotOptions): Promise<ScreenshotResult> {
try {
const svgData = await this.elementToSVG(element)
const dataUrl = `data:image/svg+xml;base64,${btoa(svgData)}`
if (options.format === "svg") {
return {
success: true,
dataUrl,
method: "svg",
}
}
// 转换SVG为其他格式
const canvas = document.createElement("canvas")
const ctx = canvas.getContext("2d")
if (!ctx) {
throw new Error("无法获取Canvas上下文")
}
const img = new Image()
return new Promise((resolve, reject) => {
img.onload = () => {
const scale = options.scale || 1
canvas.width = (options.width || img.width) * scale
canvas.height = (options.height || img.height) * scale
ctx.scale(scale, scale)
ctx.fillStyle = options.backgroundColor || "#ffffff"
ctx.fillRect(0, 0, canvas.width / scale, canvas.height / scale)
ctx.drawImage(img, 0, 0)
const finalDataUrl = canvas.toDataURL(`image/${options.format || "png"}`, options.quality || 0.95)
canvas.toBlob(
(blob) => {
resolve({
success: true,
dataUrl: finalDataUrl,
blob: blob || undefined,
method: "svg-to-canvas",
})
},
`image/${options.format || "png"}`,
options.quality || 0.95,
)
}
img.onerror = () => reject(new Error("SVG转换失败"))
img.src = dataUrl
})
} catch (error) {
throw new Error(`SVG 截图失败: ${error}`)
}
}
private async elementToSVG(element: HTMLElement): Promise<string> {
const rect = element.getBoundingClientRect()
const computedStyle = window.getComputedStyle(element)
// 获取所有样式
const styles = this.getAllStyles()
const svg = `
<svg xmlns="http://www.w3.org/2000/svg"
width="${rect.width}"
height="${rect.height}">
<defs>
<style type="text/css">
<![CDATA[
${styles}
]]>
</style>
</defs>
<foreignObject width="100%" height="100%">
<div xmlns="http://www.w3.org/1999/xhtml">
${element.outerHTML}
</div>
</foreignObject>
</svg>
`
return svg
}
private getAllStyles(): string {
let styles = ""
// 获取所有样式表
for (let i = 0; i < document.styleSheets.length; i++) {
try {
const styleSheet = document.styleSheets[i]
if (styleSheet.cssRules) {
for (let j = 0; j < styleSheet.cssRules.length; j++) {
styles += styleSheet.cssRules[j].cssText + "\n"
}
}
} catch (e) {
// 跨域样式表可能无法访问
console.warn("无法访问样式表:", e)
}
}
return styles
}
private async waitForAssets(element: HTMLElement): Promise<void> {
const images = element.querySelectorAll("img")
const promises: Promise<void>[] = []
images.forEach((img) => {
if (!img.complete) {
promises.push(
new Promise((resolve) => {
img.onload = () => resolve()
img.onerror = () => resolve() // 即使加载失败也继续
setTimeout(() => resolve(), 5000) // 5秒超时
}),
)
}
})
// 等待字体加载
if (document.fonts) {
promises.push(document.fonts.ready.catch(() => {}))
}
await Promise.all(promises)
// 额外等待确保渲染完成
await this.delay(500)
}
private delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms))
}
async captureFullPage(options: ScreenshotOptions = {}): Promise<ScreenshotResult> {
try {
// 标记body元素用于截图
document.body.setAttribute("data-screenshot-target", "true")
const result = await this.captureElement(document.body, {
...options,
width: window.innerWidth,
height: document.body.scrollHeight,
})
document.body.removeAttribute("data-screenshot-target")
return result
} catch (error) {
document.body.removeAttribute("data-screenshot-target")
throw error
}
}
async captureViewport(options: ScreenshotOptions = {}): Promise<ScreenshotResult> {
try {
// 创建一个包含当前视口的容器
const viewport = document.createElement("div")
viewport.style.position = "fixed"
viewport.style.top = "0"
viewport.style.left = "0"
viewport.style.width = "100vw"
viewport.style.height = "100vh"
viewport.style.pointerEvents = "none"
viewport.style.zIndex = "9999"
// 克隆当前视口内容
const bodyClone = document.body.cloneNode(true) as HTMLElement
viewport.appendChild(bodyClone)
document.body.appendChild(viewport)
const result = await this.captureElement(viewport, {
...options,
width: window.innerWidth,
height: window.innerHeight,
})
document.body.removeChild(viewport)
return result
} catch (error) {
throw new Error(`视口截图失败: ${error}`)
}
}
}
export const enhancedScreenshotService = new EnhancedScreenshotService()