Fix CSS issues, add missing files, and optimize documentation page. Co-authored-by: null <4804959+fnvtk@users.noreply.github.com>
687 lines
20 KiB
TypeScript
687 lines
20 KiB
TypeScript
/**
|
||
* 增强版截图服务
|
||
* 结合多种技术方案确保能够成功捕获页面截图
|
||
*/
|
||
|
||
export interface ScreenshotOptions {
|
||
format?: "png" | "jpeg" | "webp"
|
||
quality?: number
|
||
scale?: number
|
||
backgroundColor?: string
|
||
timeout?: number
|
||
width?: number
|
||
height?: number
|
||
}
|
||
|
||
export interface ScreenshotResult {
|
||
success: boolean
|
||
dataUrl?: string
|
||
error?: string
|
||
method?: string
|
||
}
|
||
|
||
class ScreenshotService {
|
||
async captureViewport(options: ScreenshotOptions = {}): Promise<ScreenshotResult> {
|
||
try {
|
||
// 方法1: 使用 html2canvas
|
||
if (typeof window !== "undefined" && window.html2canvas) {
|
||
const canvas = await window.html2canvas(document.body, {
|
||
backgroundColor: options.backgroundColor || "#ffffff",
|
||
scale: options.scale || 1,
|
||
width: options.width,
|
||
height: options.height,
|
||
useCORS: true,
|
||
allowTaint: true,
|
||
})
|
||
|
||
const dataUrl = canvas.toDataURL(`image/${options.format || "png"}`, options.quality || 0.9)
|
||
|
||
return {
|
||
success: true,
|
||
dataUrl,
|
||
method: "html2canvas",
|
||
}
|
||
}
|
||
|
||
// 方法2: 使用 dom-to-image
|
||
if (typeof window !== "undefined" && window.domtoimage) {
|
||
const dataUrl = await window.domtoimage.toPng(document.body, {
|
||
bgcolor: options.backgroundColor || "#ffffff",
|
||
width: options.width,
|
||
height: options.height,
|
||
style: {
|
||
transform: `scale(${options.scale || 1})`,
|
||
transformOrigin: "top left",
|
||
},
|
||
})
|
||
|
||
return {
|
||
success: true,
|
||
dataUrl,
|
||
method: "dom-to-image",
|
||
}
|
||
}
|
||
|
||
// 方法3: 使用 Canvas API (基础实现)
|
||
const canvas = document.createElement("canvas")
|
||
const ctx = canvas.getContext("2d")
|
||
|
||
if (!ctx) {
|
||
throw new Error("无法获取Canvas上下文")
|
||
}
|
||
|
||
canvas.width = options.width || window.innerWidth
|
||
canvas.height = options.height || window.innerHeight
|
||
|
||
// 设置背景色
|
||
ctx.fillStyle = options.backgroundColor || "#ffffff"
|
||
ctx.fillRect(0, 0, canvas.width, canvas.height)
|
||
|
||
// 简单的文本渲染作为占位符
|
||
ctx.fillStyle = "#333333"
|
||
ctx.font = "16px Arial"
|
||
ctx.fillText("页面截图占位符", 50, 50)
|
||
ctx.fillText(`URL: ${window.location.href}`, 50, 80)
|
||
ctx.fillText(`时间: ${new Date().toLocaleString()}`, 50, 110)
|
||
|
||
const dataUrl = canvas.toDataURL(`image/${options.format || "png"}`, options.quality || 0.9)
|
||
|
||
return {
|
||
success: true,
|
||
dataUrl,
|
||
method: "canvas-fallback",
|
||
}
|
||
} catch (error) {
|
||
console.error("截图失败:", error)
|
||
return {
|
||
success: false,
|
||
error: error instanceof Error ? error.message : "未知错误",
|
||
}
|
||
}
|
||
}
|
||
|
||
async captureElement(element: HTMLElement, options: ScreenshotOptions = {}): Promise<ScreenshotResult> {
|
||
try {
|
||
// 使用 html2canvas 截取特定元素
|
||
if (typeof window !== "undefined" && window.html2canvas) {
|
||
const canvas = await window.html2canvas(element, {
|
||
backgroundColor: options.backgroundColor || "#ffffff",
|
||
scale: options.scale || 1,
|
||
width: options.width,
|
||
height: options.height,
|
||
useCORS: true,
|
||
allowTaint: true,
|
||
})
|
||
|
||
const dataUrl = canvas.toDataURL(`image/${options.format || "png"}`, options.quality || 0.9)
|
||
|
||
return {
|
||
success: true,
|
||
dataUrl,
|
||
method: "html2canvas-element",
|
||
}
|
||
}
|
||
|
||
// 使用 dom-to-image 截取特定元素
|
||
if (typeof window !== "undefined" && window.domtoimage) {
|
||
const dataUrl = await window.domtoimage.toPng(element, {
|
||
bgcolor: options.backgroundColor || "#ffffff",
|
||
width: options.width,
|
||
height: options.height,
|
||
})
|
||
|
||
return {
|
||
success: true,
|
||
dataUrl,
|
||
method: "dom-to-image-element",
|
||
}
|
||
}
|
||
|
||
throw new Error("没有可用的截图库")
|
||
} catch (error) {
|
||
console.error("元素截图失败:", error)
|
||
return {
|
||
success: false,
|
||
error: error instanceof Error ? error.message : "未知错误",
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
export const screenshotService = new ScreenshotService()
|
||
|
||
// 扩展 Window 接口以包含截图库
|
||
declare global {
|
||
interface Window {
|
||
html2canvas?: any
|
||
domtoimage?: any
|
||
}
|
||
}
|
||
|
||
// 动态导入所需库
|
||
let htmlToImageModule: any = null
|
||
let domToImageModule: any = null
|
||
|
||
// 加载所需库
|
||
async function loadScreenshotLibraries() {
|
||
if (!htmlToImageModule) {
|
||
try {
|
||
htmlToImageModule = await import("html-to-image")
|
||
console.log("html-to-image库加载成功")
|
||
} catch (error) {
|
||
console.warn("html-to-image库加载失败:", error)
|
||
}
|
||
}
|
||
|
||
if (!domToImageModule) {
|
||
try {
|
||
domToImageModule = await import("dom-to-image")
|
||
console.log("dom-to-image库加载成功")
|
||
} catch (error) {
|
||
console.warn("dom-to-image库加载失败:", error)
|
||
}
|
||
}
|
||
|
||
return {
|
||
htmlToImage: htmlToImageModule,
|
||
domToImage: domToImageModule,
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 使用多种方法尝试捕获页面截图
|
||
* @param targetElement 目标元素(iframe或窗口)
|
||
* @param url 页面URL
|
||
* @param options 配置选项
|
||
* @returns 截图的base64数据URL
|
||
*/
|
||
export async function captureScreenshot(
|
||
targetElement: HTMLIFrameElement | Window,
|
||
url: string,
|
||
options: {
|
||
maxRetries?: number
|
||
retryDelay?: number
|
||
useDirectCapture?: boolean
|
||
useWindowOpen?: boolean
|
||
} = {},
|
||
): Promise<string> {
|
||
const { maxRetries = 3, retryDelay = 1000, useDirectCapture = true, useWindowOpen = false } = options
|
||
|
||
let lastError: any = null
|
||
|
||
// 尝试多种方法捕获截图
|
||
for (let retry = 0; retry < maxRetries; retry++) {
|
||
try {
|
||
// 如果重试,等待一段时间
|
||
if (retry > 0) {
|
||
await new Promise((resolve) => setTimeout(resolve, retryDelay))
|
||
console.log(`尝试第 ${retry + 1} 次捕获截图...`)
|
||
}
|
||
|
||
// 方法1: 使用html-to-image或dom-to-image库
|
||
if (useDirectCapture) {
|
||
const libraries = await loadScreenshotLibraries()
|
||
|
||
// 确定目标元素
|
||
let element: HTMLElement | null = null
|
||
|
||
if (targetElement instanceof HTMLIFrameElement) {
|
||
try {
|
||
// 尝试访问iframe内容
|
||
const iframeDocument = targetElement.contentDocument || targetElement.contentWindow?.document
|
||
if (iframeDocument) {
|
||
element = iframeDocument.documentElement
|
||
}
|
||
} catch (error) {
|
||
console.warn("无法访问iframe内容,可能是跨域限制:", error)
|
||
}
|
||
} else if (targetElement instanceof Window) {
|
||
element = targetElement.document.documentElement
|
||
}
|
||
|
||
if (element) {
|
||
// 尝试使用html-to-image
|
||
if (libraries.htmlToImage) {
|
||
try {
|
||
console.log("使用html-to-image捕获截图...")
|
||
const dataUrl = await libraries.htmlToImage.toPng(element, {
|
||
quality: options.quality || 0.95,
|
||
cacheBust: true,
|
||
pixelRatio: options.scale || 2,
|
||
skipAutoScale: true,
|
||
style: {
|
||
"background-color": options.backgroundColor || "#ffffff",
|
||
},
|
||
})
|
||
if (dataUrl && dataUrl.startsWith("data:image/png;base64,")) {
|
||
console.log("html-to-image捕获成功!")
|
||
return dataUrl
|
||
}
|
||
} catch (error) {
|
||
console.warn("html-to-image捕获失败:", error)
|
||
}
|
||
}
|
||
|
||
// 尝试使用dom-to-image
|
||
if (libraries.domToImage) {
|
||
try {
|
||
console.log("使用dom-to-image捕获截图...")
|
||
const dataUrl = await libraries.domToImage.toPng(element, {
|
||
quality: options.quality || 0.95,
|
||
bgcolor: options.backgroundColor || "#ffffff",
|
||
scale: options.scale || 2,
|
||
})
|
||
if (dataUrl && dataUrl.startsWith("data:image/png;base64,")) {
|
||
console.log("dom-to-image捕获成功!")
|
||
return dataUrl
|
||
}
|
||
} catch (error) {
|
||
console.warn("dom-to-image捕获失败:", error)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// 方法2: 使用Canvas API
|
||
try {
|
||
console.log("使用Canvas API捕获截图...")
|
||
const screenshot = await captureWithCanvas(targetElement)
|
||
if (screenshot) {
|
||
console.log("Canvas API捕获成功!")
|
||
return screenshot
|
||
}
|
||
} catch (error) {
|
||
console.warn("Canvas API捕获失败:", error)
|
||
lastError = error
|
||
}
|
||
|
||
// 方法3: 使用postMessage通信
|
||
if (targetElement instanceof HTMLIFrameElement) {
|
||
try {
|
||
console.log("使用postMessage尝试捕获截图...")
|
||
const screenshot = await captureWithPostMessage(targetElement)
|
||
if (screenshot) {
|
||
console.log("postMessage捕获成功!")
|
||
return screenshot
|
||
}
|
||
} catch (error) {
|
||
console.warn("postMessage捕获失败:", error)
|
||
lastError = error
|
||
}
|
||
}
|
||
|
||
// 方法4: 使用window.open打开页面
|
||
if (useWindowOpen) {
|
||
try {
|
||
console.log("使用window.open尝试捕获截图...")
|
||
const screenshot = await captureWithWindowOpen(url)
|
||
if (screenshot) {
|
||
console.log("window.open捕获成功!")
|
||
return screenshot
|
||
}
|
||
} catch (error) {
|
||
console.warn("window.open捕获失败:", error)
|
||
lastError = error
|
||
}
|
||
}
|
||
} catch (error) {
|
||
console.error(`第 ${retry + 1} 次捕获截图失败:`, error)
|
||
lastError = error
|
||
}
|
||
}
|
||
|
||
// 所有方法都失败,使用备用方案
|
||
console.warn("所有截图方法都失败,使用备用方案")
|
||
return createFallbackScreenshot(url, lastError)
|
||
}
|
||
|
||
/**
|
||
* 使用Canvas API捕获截图
|
||
*/
|
||
async function captureWithCanvas(target: HTMLIFrameElement | Window): Promise<string | null> {
|
||
try {
|
||
let document: Document | null = null
|
||
let width = 1200
|
||
let height = 800
|
||
|
||
if (target instanceof HTMLIFrameElement) {
|
||
if (!target.contentDocument && !target.contentWindow?.document) {
|
||
throw new Error("无法访问iframe内容")
|
||
}
|
||
document = target.contentDocument || target.contentWindow?.document || null
|
||
width = target.offsetWidth || width
|
||
height = target.offsetHeight || height
|
||
} else {
|
||
document = target.document
|
||
width = target.innerWidth
|
||
height = target.innerHeight
|
||
}
|
||
|
||
if (!document) {
|
||
throw new Error("无法获取文档对象")
|
||
}
|
||
|
||
// 创建canvas
|
||
const canvas = document.createElement("canvas")
|
||
canvas.width = width
|
||
canvas.height = height
|
||
|
||
const ctx = canvas.getContext("2d")
|
||
if (!ctx) {
|
||
throw new Error("无法创建canvas上下文")
|
||
}
|
||
|
||
// 绘制白色背景
|
||
ctx.fillStyle = "#ffffff"
|
||
ctx.fillRect(0, 0, width, height)
|
||
|
||
// 创建一个图像对象
|
||
const img = new Image()
|
||
img.crossOrigin = "anonymous"
|
||
|
||
// 将文档转换为SVG数据URL
|
||
const data = new XMLSerializer().serializeToString(document.documentElement)
|
||
const svg = new Blob([data], { type: "image/svg+xml" })
|
||
const svgUrl = URL.createObjectURL(svg)
|
||
|
||
// 等待图像加载
|
||
await new Promise((resolve, reject) => {
|
||
img.onload = resolve
|
||
img.onerror = reject
|
||
img.src = svgUrl
|
||
})
|
||
|
||
// 绘制图像
|
||
ctx.drawImage(img, 0, 0, width, height)
|
||
URL.revokeObjectURL(svgUrl)
|
||
|
||
// 返回数据URL
|
||
return canvas.toDataURL("image/png")
|
||
} catch (error) {
|
||
console.error("Canvas捕获失败:", error)
|
||
return null
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 使用postMessage与iframe通信捕获截图
|
||
*/
|
||
async function captureWithPostMessage(iframe: HTMLIFrameElement): Promise<string | null> {
|
||
return new Promise((resolve, reject) => {
|
||
// 设置超时
|
||
const timeout = setTimeout(() => {
|
||
window.removeEventListener("message", messageHandler)
|
||
reject(new Error("postMessage截图超时"))
|
||
}, options.timeout || 5000)
|
||
|
||
// 消息处理函数
|
||
function messageHandler(event: MessageEvent) {
|
||
if (event.data && event.data.type === "screenshot" && event.data.dataUrl) {
|
||
clearTimeout(timeout)
|
||
window.removeEventListener("message", messageHandler)
|
||
resolve(event.data.dataUrl)
|
||
}
|
||
}
|
||
|
||
// 监听消息
|
||
window.addEventListener("message", messageHandler)
|
||
|
||
// 向iframe发送消息
|
||
try {
|
||
iframe.contentWindow?.postMessage({ type: "requestScreenshot" }, "*")
|
||
} catch (error) {
|
||
clearTimeout(timeout)
|
||
window.removeEventListener("message", messageHandler)
|
||
reject(error)
|
||
}
|
||
})
|
||
}
|
||
|
||
/**
|
||
* 使用window.open打开页面并捕获截图
|
||
*/
|
||
async function captureWithWindowOpen(url: string): Promise<string | null> {
|
||
return new Promise((resolve, reject) => {
|
||
try {
|
||
// 打开新窗口
|
||
const newWindow = window.open(url, "_blank", "width=1200,height=800")
|
||
|
||
if (!newWindow) {
|
||
reject(new Error("无法打开新窗口,可能被浏览器阻止"))
|
||
return
|
||
}
|
||
|
||
// 设置超时
|
||
const timeout = setTimeout(() => {
|
||
if (newWindow) newWindow.close()
|
||
reject(new Error("window.open截图超时"))
|
||
}, 10000)
|
||
|
||
// 等待页面加载完成
|
||
newWindow.onload = async () => {
|
||
try {
|
||
// 等待一段时间,确保页面完全渲染
|
||
await new Promise((resolve) => setTimeout(resolve, 2000))
|
||
|
||
// 尝试捕获截图
|
||
const libraries = await loadScreenshotLibraries()
|
||
let screenshot = null
|
||
|
||
if (libraries.htmlToImage) {
|
||
try {
|
||
screenshot = await libraries.htmlToImage.toPng(newWindow.document.documentElement)
|
||
} catch (error) {
|
||
console.warn("html-to-image捕获失败:", error)
|
||
}
|
||
}
|
||
|
||
if (!screenshot && libraries.domToImage) {
|
||
try {
|
||
screenshot = await libraries.domToImage.toPng(newWindow.document.documentElement)
|
||
} catch (error) {
|
||
console.warn("dom-to-image捕获失败:", error)
|
||
}
|
||
}
|
||
|
||
// 关闭窗口
|
||
newWindow.close()
|
||
clearTimeout(timeout)
|
||
|
||
if (screenshot) {
|
||
resolve(screenshot)
|
||
} else {
|
||
reject(new Error("无法捕获新窗口的截图"))
|
||
}
|
||
} catch (error) {
|
||
newWindow.close()
|
||
clearTimeout(timeout)
|
||
reject(error)
|
||
}
|
||
}
|
||
|
||
// 处理加载错误
|
||
newWindow.onerror = (event) => {
|
||
newWindow.close()
|
||
clearTimeout(timeout)
|
||
reject(new Error("新窗口加载出错"))
|
||
}
|
||
} catch (error) {
|
||
reject(error)
|
||
}
|
||
})
|
||
}
|
||
|
||
/**
|
||
* 创建备用截图
|
||
* 当所有方法都失败时,生成一个包含页面信息的模拟截图
|
||
*/
|
||
function createFallbackScreenshot(url: string, error: any): string {
|
||
// 提取页面标题
|
||
const pathParts = url.split("/")
|
||
const pageName = pathParts[pathParts.length - 1] || "页面"
|
||
|
||
// 创建canvas
|
||
const canvas = document.createElement("canvas")
|
||
const ctx = canvas.getContext("2d")
|
||
|
||
if (!ctx) {
|
||
throw new Error("无法创建canvas上下文")
|
||
}
|
||
|
||
// 设置canvas尺寸
|
||
const width = 1200
|
||
const height = 800
|
||
canvas.width = width
|
||
canvas.height = height
|
||
|
||
// 绘制背景
|
||
ctx.fillStyle = "#ffffff"
|
||
ctx.fillRect(0, 0, width, height)
|
||
|
||
// 绘制边框
|
||
ctx.strokeStyle = "#e5e7eb"
|
||
ctx.lineWidth = 2
|
||
ctx.strokeRect(1, 1, width - 2, height - 2)
|
||
|
||
// 绘制页面信息
|
||
ctx.fillStyle = "#f9fafb"
|
||
ctx.fillRect(0, 0, width, 80)
|
||
|
||
ctx.fillStyle = "#111827"
|
||
ctx.font = "bold 24px system-ui"
|
||
ctx.fillText(pageName, 20, 40)
|
||
|
||
ctx.fillStyle = "#6b7280"
|
||
ctx.font = "14px system-ui"
|
||
ctx.fillText(url, 20, 65)
|
||
|
||
// 绘制内容区域
|
||
ctx.fillStyle = "#f3f4f6"
|
||
ctx.fillRect(20, 100, width - 40, height - 120)
|
||
|
||
// 添加错误信息
|
||
ctx.fillStyle = "#ef4444"
|
||
ctx.font = "16px system-ui"
|
||
ctx.fillText("截图捕获失败", 40, 140)
|
||
|
||
ctx.fillStyle = "#6b7280"
|
||
ctx.font = "14px system-ui"
|
||
ctx.fillText(`错误信息: ${error ? error.message || "未知错误" : "未知错误"}`, 40, 170)
|
||
ctx.fillText(`URL: ${url}`, 40, 200)
|
||
ctx.fillText("请尝试手动截图或刷新页面后重试", 40, 230)
|
||
|
||
// 添加水印
|
||
ctx.save()
|
||
ctx.globalAlpha = 0.1
|
||
ctx.fillStyle = "#000000"
|
||
ctx.font = "bold 48px system-ui"
|
||
ctx.translate(width / 2, height / 2)
|
||
ctx.rotate(-Math.PI / 6)
|
||
ctx.textAlign = "center"
|
||
ctx.fillText("备用截图", 0, 0)
|
||
ctx.restore()
|
||
|
||
// 返回base64数据
|
||
return canvas.toDataURL("image/png")
|
||
}
|
||
|
||
/**
|
||
* 注入截图脚本到页面
|
||
* 这个函数会向页面注入一个脚本,使页面能够响应截图请求
|
||
*/
|
||
export function injectScreenshotScript(window: Window): void {
|
||
try {
|
||
const script = window.document.createElement("script")
|
||
script.textContent = `
|
||
// 监听截图请求
|
||
window.addEventListener('message', async function(event) {
|
||
if (event.data && event.data.type === 'requestScreenshot') {
|
||
try {
|
||
// 动态加载html2canvas
|
||
const script = document.createElement('script');
|
||
script.src = 'https://html2canvas.hertzen.com/dist/html2canvas.min.js';
|
||
script.onload = async function() {
|
||
try {
|
||
// 使用html2canvas捕获页面
|
||
const canvas = await html2canvas(document.documentElement, {
|
||
allowTaint: true,
|
||
useCORS: true,
|
||
scale: ${options.scale || 2},
|
||
backgroundColor: '${options.backgroundColor || "#ffffff"}'
|
||
});
|
||
|
||
// 发送截图数据
|
||
const dataUrl = canvas.toDataURL('image/${options.format || "png"}', ${options.quality || 0.9});
|
||
window.parent.postMessage({
|
||
type: 'screenshot',
|
||
dataUrl: dataUrl
|
||
}, '*');
|
||
} catch (error) {
|
||
console.error('html2canvas捕获失败:', error);
|
||
window.parent.postMessage({
|
||
type: 'screenshotError',
|
||
error: error.toString()
|
||
}, '*');
|
||
}
|
||
};
|
||
script.onerror = function(error) {
|
||
console.error('加载html2canvas失败:', error);
|
||
window.parent.postMessage({
|
||
type: 'screenshotError',
|
||
error: 'Failed to load html2canvas'
|
||
}, '*');
|
||
};
|
||
document.head.appendChild(script);
|
||
} catch (error) {
|
||
console.error('处理截图请求时出错:', error);
|
||
window.parent.postMessage({
|
||
type: 'screenshotError',
|
||
error: error.toString()
|
||
}, '*');
|
||
}
|
||
}
|
||
});
|
||
|
||
console.log('截图脚本已注入');
|
||
`
|
||
window.document.head.appendChild(script)
|
||
} catch (error) {
|
||
console.error("注入截图脚本失败:", error)
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 使用服务器端API捕获截图
|
||
* 注意:这需要后端支持
|
||
*/
|
||
export async function captureWithServerAPI(url: string): Promise<string | null> {
|
||
try {
|
||
// 这里应该调用后端API
|
||
// 由于我们没有实际的后端API,这里只是一个示例
|
||
console.log("尝试使用服务器端API捕获截图:", url)
|
||
|
||
// 模拟API调用
|
||
const response = await fetch("/api/screenshot", {
|
||
method: "POST",
|
||
headers: {
|
||
"Content-Type": "application/json",
|
||
},
|
||
body: JSON.stringify({ url }),
|
||
})
|
||
|
||
if (!response.ok) {
|
||
throw new Error(`API请求失败: ${response.status} ${response.statusText}`)
|
||
}
|
||
|
||
const data = await response.json()
|
||
if (data.success && data.screenshot) {
|
||
return data.screenshot
|
||
} else {
|
||
throw new Error(data.error || "未知错误")
|
||
}
|
||
} catch (error) {
|
||
console.error("服务器端API捕获失败:", error)
|
||
return null
|
||
}
|
||
}
|