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>
391 lines
11 KiB
TypeScript
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()
|