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>
This commit is contained in:
468
lib/documentation/docx-generator.ts
Normal file
468
lib/documentation/docx-generator.ts
Normal file
@@ -0,0 +1,468 @@
|
||||
/**
|
||||
* Word文档生成器
|
||||
* 使用docx库生成专业的Word文档
|
||||
*/
|
||||
|
||||
import {
|
||||
Document,
|
||||
Paragraph,
|
||||
TextRun,
|
||||
HeadingLevel,
|
||||
ImageRun,
|
||||
TableOfContents,
|
||||
PageBreak,
|
||||
AlignmentType,
|
||||
Table,
|
||||
TableRow,
|
||||
TableCell,
|
||||
WidthType,
|
||||
BorderStyle,
|
||||
} from "docx"
|
||||
|
||||
interface DocumentSection {
|
||||
title: string
|
||||
path: string
|
||||
description: string
|
||||
screenshot: string
|
||||
}
|
||||
|
||||
interface DocumentData {
|
||||
title: string
|
||||
author: string
|
||||
date: string
|
||||
sections: DocumentSection[]
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成Word文档
|
||||
* @param data 文档数据
|
||||
* @returns 文档的blob URL
|
||||
*/
|
||||
export async function generateDocx(data: DocumentData): Promise<string> {
|
||||
console.log("开始生成Word文档...")
|
||||
|
||||
try {
|
||||
// 验证输入数据
|
||||
if (!data) {
|
||||
throw new Error("文档数据不能为空")
|
||||
}
|
||||
|
||||
if (!Array.isArray(data.sections)) {
|
||||
console.warn("sections不是数组,使用空数组代替")
|
||||
data.sections = []
|
||||
}
|
||||
|
||||
console.log(`文档标题: ${data.title}`)
|
||||
console.log(`作者: ${data.author}`)
|
||||
console.log(`日期: ${data.date}`)
|
||||
console.log(`部分数量: ${data.sections.length}`)
|
||||
|
||||
// 创建文档
|
||||
const doc = new Document({
|
||||
title: data.title,
|
||||
description: "由文档生成工具自动生成",
|
||||
creator: data.author,
|
||||
styles: {
|
||||
paragraphStyles: [
|
||||
{
|
||||
id: "Normal",
|
||||
name: "Normal",
|
||||
run: {
|
||||
size: 24, // 12pt
|
||||
font: "Microsoft YaHei",
|
||||
},
|
||||
paragraph: {
|
||||
spacing: {
|
||||
line: 360, // 1.5倍行距
|
||||
before: 240, // 12pt
|
||||
after: 240, // 12pt
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "Heading1",
|
||||
name: "Heading 1",
|
||||
run: {
|
||||
size: 36, // 18pt
|
||||
bold: true,
|
||||
font: "Microsoft YaHei",
|
||||
},
|
||||
paragraph: {
|
||||
spacing: {
|
||||
before: 480, // 24pt
|
||||
after: 240, // 12pt
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "Heading2",
|
||||
name: "Heading 2",
|
||||
run: {
|
||||
size: 32, // 16pt
|
||||
bold: true,
|
||||
font: "Microsoft YaHei",
|
||||
},
|
||||
paragraph: {
|
||||
spacing: {
|
||||
before: 360, // 18pt
|
||||
after: 240, // 12pt
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "Caption",
|
||||
name: "Caption",
|
||||
run: {
|
||||
size: 20, // 10pt
|
||||
italic: true,
|
||||
font: "Microsoft YaHei",
|
||||
},
|
||||
paragraph: {
|
||||
alignment: AlignmentType.CENTER,
|
||||
spacing: {
|
||||
before: 120, // 6pt
|
||||
after: 240, // 12pt
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
// 文档部分
|
||||
const sections = []
|
||||
|
||||
// 封面
|
||||
sections.push({
|
||||
properties: {},
|
||||
children: [
|
||||
new Paragraph({
|
||||
text: "",
|
||||
spacing: {
|
||||
before: 3000, // 大约页面1/3处
|
||||
},
|
||||
}),
|
||||
new Paragraph({
|
||||
text: data.title,
|
||||
heading: HeadingLevel.TITLE,
|
||||
alignment: AlignmentType.CENTER,
|
||||
spacing: {
|
||||
after: 400,
|
||||
},
|
||||
}),
|
||||
new Paragraph({
|
||||
text: "",
|
||||
spacing: {
|
||||
before: 800,
|
||||
},
|
||||
}),
|
||||
new Paragraph({
|
||||
alignment: AlignmentType.CENTER,
|
||||
children: [
|
||||
new TextRun({
|
||||
text: `作者: ${data.author}`,
|
||||
size: 24,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
new Paragraph({
|
||||
alignment: AlignmentType.CENTER,
|
||||
children: [
|
||||
new TextRun({
|
||||
text: `生成日期: ${data.date}`,
|
||||
size: 24,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
new Paragraph({
|
||||
text: "",
|
||||
break: PageBreak.AFTER,
|
||||
}),
|
||||
],
|
||||
})
|
||||
|
||||
// 目录
|
||||
sections.push({
|
||||
properties: {},
|
||||
children: [
|
||||
new Paragraph({
|
||||
text: "目录",
|
||||
heading: HeadingLevel.HEADING_1,
|
||||
alignment: AlignmentType.CENTER,
|
||||
}),
|
||||
new TableOfContents("目录", {
|
||||
hyperlink: true,
|
||||
headingStyleRange: "1-3",
|
||||
}),
|
||||
new Paragraph({
|
||||
text: "",
|
||||
break: PageBreak.AFTER,
|
||||
}),
|
||||
],
|
||||
})
|
||||
|
||||
// 正文
|
||||
const contentSection = {
|
||||
properties: {},
|
||||
children: [] as any[],
|
||||
}
|
||||
|
||||
// 添加简介
|
||||
contentSection.children.push(
|
||||
new Paragraph({
|
||||
text: "1. 简介",
|
||||
heading: HeadingLevel.HEADING_1,
|
||||
}),
|
||||
new Paragraph({
|
||||
text: "本文档由文档生成工具自动生成,包含应用程序的所有主要页面截图和功能说明。",
|
||||
}),
|
||||
new Paragraph({
|
||||
text: "文档目的是帮助用户了解系统功能和使用方法,为系统管理员和最终用户提供参考。",
|
||||
}),
|
||||
new Paragraph({
|
||||
text: "",
|
||||
}),
|
||||
)
|
||||
|
||||
// 添加页面内容
|
||||
contentSection.children.push(
|
||||
new Paragraph({
|
||||
text: "2. 系统功能",
|
||||
heading: HeadingLevel.HEADING_1,
|
||||
}),
|
||||
)
|
||||
|
||||
// 处理每个部分
|
||||
console.log("开始处理文档部分...")
|
||||
|
||||
// 使用for循环而不是forEach,以便更好地处理错误
|
||||
for (let i = 0; i < data.sections.length; i++) {
|
||||
try {
|
||||
const section = data.sections[i]
|
||||
console.log(`处理部分 ${i + 1}/${data.sections.length}: ${section.title}`)
|
||||
|
||||
// 验证部分数据
|
||||
if (!section.title) {
|
||||
console.warn(`部分 ${i + 1} 缺少标题,使用默认标题`)
|
||||
section.title = `页面 ${i + 1}`
|
||||
}
|
||||
|
||||
if (!section.description) {
|
||||
console.warn(`部分 ${i + 1} 缺少描述,使用默认描述`)
|
||||
section.description = `这是 ${section.title} 页面的描述。`
|
||||
}
|
||||
|
||||
// 添加标题
|
||||
contentSection.children.push(
|
||||
new Paragraph({
|
||||
text: `2.${i + 1} ${section.title}`,
|
||||
heading: HeadingLevel.HEADING_2,
|
||||
}),
|
||||
)
|
||||
|
||||
// 添加描述
|
||||
const descriptionParagraphs = section.description.split("\n")
|
||||
for (const paragraph of descriptionParagraphs) {
|
||||
contentSection.children.push(
|
||||
new Paragraph({
|
||||
text: paragraph,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
// 添加截图
|
||||
try {
|
||||
if (section.screenshot && section.screenshot.startsWith("data:image/")) {
|
||||
console.log(`处理截图: ${section.title}`)
|
||||
|
||||
// 从base64数据URL中提取图像数据
|
||||
const base64Data = section.screenshot.split(",")[1]
|
||||
if (!base64Data) {
|
||||
throw new Error("无效的base64数据")
|
||||
}
|
||||
|
||||
// 将base64转换为二进制数据
|
||||
const imageBuffer = Buffer.from(base64Data, "base64")
|
||||
|
||||
// 添加图像
|
||||
contentSection.children.push(
|
||||
new Paragraph({
|
||||
children: [
|
||||
new ImageRun({
|
||||
data: imageBuffer,
|
||||
transformation: {
|
||||
width: 600,
|
||||
height: 400,
|
||||
},
|
||||
}),
|
||||
],
|
||||
alignment: AlignmentType.CENTER,
|
||||
}),
|
||||
new Paragraph({
|
||||
text: `图 ${i + 1}: ${section.title} 页面截图`,
|
||||
style: "Caption",
|
||||
}),
|
||||
new Paragraph({
|
||||
text: "",
|
||||
}),
|
||||
)
|
||||
} else {
|
||||
console.warn(`部分 ${i + 1} 缺少有效的截图`)
|
||||
contentSection.children.push(
|
||||
new Paragraph({
|
||||
text: "[截图不可用]",
|
||||
alignment: AlignmentType.CENTER,
|
||||
}),
|
||||
new Paragraph({
|
||||
text: "",
|
||||
}),
|
||||
)
|
||||
}
|
||||
} catch (imageError) {
|
||||
console.error(`处理部分 ${i + 1} 的截图时出错:`, imageError)
|
||||
contentSection.children.push(
|
||||
new Paragraph({
|
||||
text: "[处理截图时出错]",
|
||||
alignment: AlignmentType.CENTER,
|
||||
}),
|
||||
new Paragraph({
|
||||
text: "",
|
||||
}),
|
||||
)
|
||||
}
|
||||
} catch (sectionError) {
|
||||
console.error(`处理部分 ${i + 1} 时出错:`, sectionError)
|
||||
contentSection.children.push(
|
||||
new Paragraph({
|
||||
text: `[处理部分 ${i + 1} 时出错: ${sectionError instanceof Error ? sectionError.message : String(sectionError)}]`,
|
||||
alignment: AlignmentType.CENTER,
|
||||
}),
|
||||
new Paragraph({
|
||||
text: "",
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 添加附录
|
||||
contentSection.children.push(
|
||||
new Paragraph({
|
||||
text: "3. 附录",
|
||||
heading: HeadingLevel.HEADING_1,
|
||||
}),
|
||||
new Paragraph({
|
||||
text: "3.1 文档信息",
|
||||
heading: HeadingLevel.HEADING_2,
|
||||
}),
|
||||
)
|
||||
|
||||
// 创建文档信息表格
|
||||
const infoTable = new Table({
|
||||
width: {
|
||||
size: 100,
|
||||
type: WidthType.PERCENTAGE,
|
||||
},
|
||||
borders: {
|
||||
top: { style: BorderStyle.SINGLE, size: 1, color: "auto" },
|
||||
bottom: { style: BorderStyle.SINGLE, size: 1, color: "auto" },
|
||||
left: { style: BorderStyle.SINGLE, size: 1, color: "auto" },
|
||||
right: { style: BorderStyle.SINGLE, size: 1, color: "auto" },
|
||||
insideHorizontal: { style: BorderStyle.SINGLE, size: 1, color: "auto" },
|
||||
insideVertical: { style: BorderStyle.SINGLE, size: 1, color: "auto" },
|
||||
},
|
||||
rows: [
|
||||
new TableRow({
|
||||
children: [
|
||||
new TableCell({
|
||||
width: {
|
||||
size: 30,
|
||||
type: WidthType.PERCENTAGE,
|
||||
},
|
||||
children: [new Paragraph("文档标题")],
|
||||
}),
|
||||
new TableCell({
|
||||
width: {
|
||||
size: 70,
|
||||
type: WidthType.PERCENTAGE,
|
||||
},
|
||||
children: [new Paragraph(data.title)],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
new TableRow({
|
||||
children: [
|
||||
new TableCell({
|
||||
children: [new Paragraph("作者")],
|
||||
}),
|
||||
new TableCell({
|
||||
children: [new Paragraph(data.author)],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
new TableRow({
|
||||
children: [
|
||||
new TableCell({
|
||||
children: [new Paragraph("生成日期")],
|
||||
}),
|
||||
new TableCell({
|
||||
children: [new Paragraph(data.date)],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
new TableRow({
|
||||
children: [
|
||||
new TableCell({
|
||||
children: [new Paragraph("页面数量")],
|
||||
}),
|
||||
new TableCell({
|
||||
children: [new Paragraph(String(data.sections.length))],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
],
|
||||
})
|
||||
|
||||
contentSection.children.push(infoTable)
|
||||
|
||||
// 添加内容部分到文档
|
||||
sections.push(contentSection)
|
||||
|
||||
// 设置文档部分
|
||||
doc.addSection({
|
||||
children: [...sections[0].children, ...sections[1].children, ...contentSection.children],
|
||||
})
|
||||
|
||||
console.log("文档生成完成,准备导出...")
|
||||
|
||||
// 生成blob
|
||||
const buffer = await doc.save()
|
||||
const blob = new Blob([buffer], { type: "application/vnd.openxmlformats-officedocument.wordprocessingml.document" })
|
||||
|
||||
// 创建URL
|
||||
const url = URL.createObjectURL(blob)
|
||||
console.log("文档URL已创建:", url)
|
||||
|
||||
return url
|
||||
} catch (error) {
|
||||
console.error("生成Word文档时出错:", error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将base64字符串转换为Buffer
|
||||
* @param base64 base64字符串
|
||||
* @returns Buffer
|
||||
*/
|
||||
function base64ToBuffer(base64: string): Buffer {
|
||||
try {
|
||||
// 移除data URL前缀
|
||||
const base64Data = base64.includes("base64,") ? base64.split("base64,")[1] : base64
|
||||
|
||||
// 转换为Buffer
|
||||
return Buffer.from(base64Data, "base64")
|
||||
} catch (error) {
|
||||
console.error("base64转换为Buffer时出错:", error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
390
lib/documentation/enhanced-screenshot-service.ts
Normal file
390
lib/documentation/enhanced-screenshot-service.ts
Normal file
@@ -0,0 +1,390 @@
|
||||
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()
|
||||
182
lib/documentation/page-registry.ts
Normal file
182
lib/documentation/page-registry.ts
Normal file
@@ -0,0 +1,182 @@
|
||||
/**
|
||||
* 页面注册表
|
||||
* 包含应用程序中所有需要文档化的页面
|
||||
*/
|
||||
|
||||
interface AppPage {
|
||||
path: string
|
||||
title: string
|
||||
description: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有需要文档化的页面
|
||||
*/
|
||||
export function getAllPages(): AppPage[] {
|
||||
return [
|
||||
// 主要页面
|
||||
{
|
||||
path: "/",
|
||||
title: "系统概览",
|
||||
description:
|
||||
"用户数据资产中台的主页面,展示系统整体运行状况、关键指标和快速入口。提供数据概览、用户增长趋势、价值分布等核心信息的可视化展示。",
|
||||
},
|
||||
|
||||
// 用户管理
|
||||
{
|
||||
path: "/user-portrait",
|
||||
title: "用户画像",
|
||||
description:
|
||||
"全面展示用户的基本信息、行为特征、兴趣偏好和价值评估。通过多维度数据分析,帮助深入了解用户特征,支持精准营销和个性化服务。",
|
||||
},
|
||||
{
|
||||
path: "/user-pool",
|
||||
title: "用户池",
|
||||
description:
|
||||
"集中管理所有用户数据,提供高级筛选、分组和批量操作功能。支持基于多种条件的用户查询和导出,便于进行用户分析和营销活动。",
|
||||
},
|
||||
{
|
||||
path: "/user-value",
|
||||
title: "用户价值评估",
|
||||
description:
|
||||
"基于RFM模型和AI算法评估用户价值,识别高价值用户群体。提供用户生命周期价值预测和流失风险评估,支持制定差异化运营策略。",
|
||||
},
|
||||
|
||||
// 数据管理
|
||||
{
|
||||
path: "/data-platform",
|
||||
title: "数据中台",
|
||||
description:
|
||||
"数据管理和分析的核心平台,提供数据集成、质量监控、关联分析等功能。支持多数据源接入,实现数据的统一管理和价值挖掘。",
|
||||
},
|
||||
{
|
||||
path: "/data-integration",
|
||||
title: "数据集成",
|
||||
description:
|
||||
"配置和管理外部数据源的接入,支持API、数据库、文件等多种数据源类型。提供数据映射、转换和同步功能,确保数据的准确性和实时性。",
|
||||
},
|
||||
{
|
||||
path: "/database-structure",
|
||||
title: "数据库结构",
|
||||
description:
|
||||
"可视化展示系统数据库的表结构、字段关系和索引信息。帮助开发人员和数据分析师理解数据模型,优化查询性能。",
|
||||
},
|
||||
{
|
||||
path: "/data-dictionary",
|
||||
title: "数据字典",
|
||||
description:
|
||||
"提供系统中所有数据字段的详细定义、类型、取值范围和业务含义说明。作为数据标准化的参考文档,确保数据使用的一致性。",
|
||||
},
|
||||
|
||||
// 标签系统
|
||||
{
|
||||
path: "/tag-management",
|
||||
title: "标签管理",
|
||||
description:
|
||||
"创建和管理用户标签体系,支持手动标签和自动标签。提供标签分类、层级管理和标签组合功能,构建完整的用户标签画像。",
|
||||
},
|
||||
{
|
||||
path: "/tag-rules",
|
||||
title: "标签规则",
|
||||
description:
|
||||
"设置自动标签生成规则,基于用户行为和属性自动打标。支持复杂的条件组合和定时执行,提高标签覆盖率和准确性。",
|
||||
},
|
||||
{
|
||||
path: "/tag-tasks",
|
||||
title: "标签任务",
|
||||
description: "管理批量标签处理任务,监控任务执行状态和结果。支持定时任务和手动触发,提供任务日志和错误处理机制。",
|
||||
},
|
||||
|
||||
// 营销工具
|
||||
{
|
||||
path: "/scenarios",
|
||||
title: "营销场景",
|
||||
description: "管理各类营销场景和活动,包括拉新、促活、留存等。提供场景模板和效果分析,支持快速复制成功经验。",
|
||||
},
|
||||
{
|
||||
path: "/traffic-pool",
|
||||
title: "流量池",
|
||||
description:
|
||||
"管理和分配营销流量资源,监控流量使用情况和转化效果。支持流量预算管理和ROI分析,优化营销投入产出比。",
|
||||
},
|
||||
{
|
||||
path: "/conversion",
|
||||
title: "转化分析",
|
||||
description: "分析用户转化漏斗,识别转化瓶颈和优化机会。提供多维度转化率对比和归因分析,指导营销策略优化。",
|
||||
},
|
||||
|
||||
// 设备管理
|
||||
{
|
||||
path: "/devices",
|
||||
title: "设备管理",
|
||||
description:
|
||||
"管理接入系统的所有设备,监控设备状态和性能。支持设备分组、远程控制和批量操作,确保营销活动的正常执行。",
|
||||
},
|
||||
{
|
||||
path: "/wechat-accounts",
|
||||
title: "微信账号",
|
||||
description: "管理微信营销账号,包括个人号和公众号。提供账号状态监控、好友管理和消息发送功能,支持微信私域运营。",
|
||||
},
|
||||
|
||||
// 内容管理
|
||||
{
|
||||
path: "/content",
|
||||
title: "内容库",
|
||||
description: "集中管理营销内容素材,包括文案、图片、视频等。支持内容分类、标签和版本管理,提高内容复用效率。",
|
||||
},
|
||||
|
||||
// 工作空间
|
||||
{
|
||||
path: "/workspace",
|
||||
title: "工作空间",
|
||||
description: "个人工作台,集成常用功能和快捷操作。提供任务管理、数据看板和协作工具,提升工作效率。",
|
||||
},
|
||||
|
||||
// AI功能
|
||||
{
|
||||
path: "/ai-assistant",
|
||||
title: "AI智能助手",
|
||||
description:
|
||||
"基于人工智能的智能分析和决策支持系统。提供数据洞察、趋势预测和策略建议,辅助制定数据驱动的业务决策。",
|
||||
},
|
||||
|
||||
// API接口
|
||||
{
|
||||
path: "/api-interface",
|
||||
title: "API接口",
|
||||
description: "系统对外API接口文档和测试工具。提供接口说明、参数定义和调用示例,支持第三方系统集成。",
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据路径获取页面信息
|
||||
*/
|
||||
export function getPageByPath(path: string): AppPage | null {
|
||||
const pages = getAllPages()
|
||||
return pages.find((page) => page.path === path) || null
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取页面分类
|
||||
*/
|
||||
export function getPageCategories(): Record<string, AppPage[]> {
|
||||
const pages = getAllPages()
|
||||
const categories: Record<string, AppPage[]> = {
|
||||
系统概览: pages.filter((p) => p.path === "/"),
|
||||
用户管理: pages.filter((p) => p.path.includes("user") || p.path.includes("portrait")),
|
||||
数据管理: pages.filter((p) => p.path.includes("data") || p.path.includes("database")),
|
||||
标签系统: pages.filter((p) => p.path.includes("tag")),
|
||||
营销工具: pages.filter(
|
||||
(p) =>
|
||||
p.path.includes("scenario") ||
|
||||
p.path.includes("traffic") ||
|
||||
p.path.includes("conversion") ||
|
||||
p.path.includes("content"),
|
||||
),
|
||||
设备管理: pages.filter((p) => p.path.includes("device") || p.path.includes("wechat")),
|
||||
其他功能: pages.filter((p) => p.path.includes("workspace") || p.path.includes("ai") || p.path.includes("api")),
|
||||
}
|
||||
|
||||
return categories
|
||||
}
|
||||
531
lib/documentation/screenshot-service.ts
Normal file
531
lib/documentation/screenshot-service.ts
Normal file
@@ -0,0 +1,531 @@
|
||||
/**
|
||||
* 增强版截图服务
|
||||
* 结合多种技术方案确保能够成功捕获页面截图
|
||||
*/
|
||||
|
||||
// 动态导入所需库
|
||||
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: 0.95,
|
||||
cacheBust: true,
|
||||
pixelRatio: 2,
|
||||
skipAutoScale: true,
|
||||
style: {
|
||||
"background-color": "#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: 0.95,
|
||||
bgcolor: "#ffffff",
|
||||
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 url = URL.createObjectURL(svg)
|
||||
|
||||
// 等待图像加载
|
||||
await new Promise((resolve, reject) => {
|
||||
img.onload = resolve
|
||||
img.onerror = reject
|
||||
img.src = url
|
||||
})
|
||||
|
||||
// 绘制图像
|
||||
ctx.drawImage(img, 0, 0, width, height)
|
||||
URL.revokeObjectURL(url)
|
||||
|
||||
// 返回数据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截图超时"))
|
||||
}, 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: 2,
|
||||
backgroundColor: '#ffffff'
|
||||
});
|
||||
|
||||
// 发送截图数据
|
||||
const dataUrl = canvas.toDataURL('image/png');
|
||||
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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user