bytefun
Version:
一个打通了原型设计、UI设计与代码转换、跨平台原生代码开发等的平台
569 lines (463 loc) • 17.7 kB
text/typescript
import * as vscode from 'vscode';
import * as fs from 'fs';
import * as path from 'path';
import puppeteer, { Browser, Page, HTTPRequest } from 'puppeteer';
/**
* HTML页面截图捕获器
* 使用Puppeteer实现高质量的HTML页面截图功能
*/
export class ScreenshotCapture {
private static instance: ScreenshotCapture;
private browser: Browser | null = null;
private isInitializing: boolean = false;
private initPromise: Promise<Browser> | null = null;
private constructor() { }
/**
* 获取单例实例
*/
public static getInstance(): ScreenshotCapture {
if (!ScreenshotCapture.instance) {
ScreenshotCapture.instance = new ScreenshotCapture();
}
return ScreenshotCapture.instance;
}
/**
* 初始化Puppeteer浏览器实例
*/
private async initializeBrowser(): Promise<Browser> {
if (this.browser && this.browser.isConnected()) {
return this.browser;
}
// 如果正在初始化,等待初始化完成
if (this.isInitializing && this.initPromise) {
return this.initPromise;
}
this.isInitializing = true;
this.initPromise = puppeteer.launch({
headless: true,
defaultViewport: null,
args: [
'--no-sandbox',
'--disable-setuid-sandbox',
'--disable-dev-shm-usage',
'--disable-gpu',
'--no-first-run',
'--disable-background-timer-throttling',
'--disable-backgrounding-occluded-windows',
'--disable-renderer-backgrounding',
'--disable-features=TranslateUI',
'--disable-ipc-flooding-protection',
'--window-size=1920,1080'
],
ignoreDefaultArgs: ['--disable-extensions'],
timeout: 30000
});
try {
this.browser = await this.initPromise;
// 监听浏览器断开连接
this.browser.on('disconnected', () => {
this.browser = null;
this.isInitializing = false;
this.initPromise = null;
});
return this.browser;
} catch (error) {
console.error('❌ [ScreenshotCapture] Puppeteer浏览器初始化失败:', error);
this.isInitializing = false;
this.initPromise = null;
throw error;
} finally {
this.isInitializing = false;
}
}
/**
* 捕获HTML页面截图
* @param htmlFilePath HTML文件的完整路径
* @param workspaceRoot 工作区根目录
* @param options 截图选项
* @returns 截图文件路径,失败返回null
*/
public async captureHtmlPage(
htmlFilePath: string,
workspaceRoot: string,
options: ScreenshotOptions = {}
): Promise<ScreenshotResult> {
const startTime = Date.now();
try {
// 验证HTML文件是否存在
if (!fs.existsSync(htmlFilePath)) {
throw new Error(`HTML文件不存在: ${htmlFilePath}`);
}
// 确保截图目录存在
const screenshotDir = path.join(workspaceRoot, 'screenshot');
if (!fs.existsSync(screenshotDir)) {
fs.mkdirSync(screenshotDir, { recursive: true });
}
// 生成截图文件名
const fileName = this.generateScreenshotFileName(htmlFilePath, workspaceRoot, options.suffix);
const screenshotPath = path.join(screenshotDir, fileName);
// 初始化浏览器
const browser = await this.initializeBrowser();
// 创建新页面
const page = await browser.newPage();
try {
// 配置页面
await this.configurePage(page, options);
// 加载HTML文件
const fileUrl = `file://${htmlFilePath}`;
await page.goto(fileUrl, {
waitUntil: 'networkidle0',
timeout: options.timeout || 30000
});
// 等待页面完全渲染
await this.waitForPageReady(page, options);
// 判断是否需要截取特定元素
if (options.elementSelector) {
await this.captureElementScreenshot(page, screenshotPath, options);
} else {
// 执行全页面截图
await page.screenshot({
path: screenshotPath,
fullPage: options.fullPage !== false,
type: 'png',
omitBackground: options.transparent || false,
clip: options.clip
});
}
const duration = Date.now() - startTime;
return {
success: true,
screenshotPath,
relativePath: path.relative(workspaceRoot, screenshotPath),
duration
};
} finally {
// 确保页面被关闭
try {
await page.close();
} catch (error) {
console.warn('⚠️ [ScreenshotCapture] 关闭页面时出错:', error);
}
}
} catch (error) {
const duration = Date.now() - startTime;
console.error('❌ [ScreenshotCapture] 截图失败:', error);
return {
success: false,
error: error instanceof Error ? error.message : String(error),
duration
};
}
}
/**
* 捕获特定元素的截图
*/
private async captureElementScreenshot(page: Page, screenshotPath: string, options: ScreenshotOptions): Promise<void> {
try {
// 等待元素出现
await page.waitForSelector(options.elementSelector!, {
timeout: options.selectorTimeout || 10000
});
// 获取元素
const element = await page.$(options.elementSelector!);
if (!element) {
throw new Error(`未找到元素: ${options.elementSelector}`);
}
// 直接对元素进行截图
await element.screenshot({
path: screenshotPath,
type: 'png',
omitBackground: options.transparent || false
});
} catch (error) {
console.error(`❌ [ScreenshotCapture] 元素截图失败:`, error);
// 如果元素截图失败,回退到全页面截图
await page.screenshot({
path: screenshotPath,
fullPage: options.fullPage !== false,
type: 'png',
omitBackground: options.transparent || false,
clip: options.clip
});
}
}
/**
* 配置页面参数
*/
private async configurePage(page: Page, options: ScreenshotOptions): Promise<void> {
// 设置视窗大小
await page.setViewport({
width: options.width || 1920,
height: options.height || 1080,
deviceScaleFactor: options.deviceScaleFactor || 2
});
// 设置用户代理
await page.setUserAgent(
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
);
// 拦截不必要的资源请求以提高性能
if (options.blockResources) {
await page.setRequestInterception(true);
page.on('request', (request: HTTPRequest) => {
const resourceType = request.resourceType();
const url = request.url();
// 只拦截外部的字体和图片,保留本地文件
if (['font', 'image'].includes(resourceType) && !url.startsWith('file://') && !url.startsWith('data:')) {
request.abort();
} else {
request.continue();
}
});
}
// 禁用JavaScript(如果需要)
if (options.disableJavaScript) {
await page.setJavaScriptEnabled(false);
}
}
/**
* 等待页面完全加载
*/
private async waitForPageReady(page: Page, options: ScreenshotOptions): Promise<void> {
// 基础等待时间
const baseWaitTime = options.waitTime || 1000;
await page.waitForTimeout(baseWaitTime);
// 等待特定元素(如果指定)
if (options.waitForSelector) {
try {
await page.waitForSelector(options.waitForSelector, {
timeout: options.selectorTimeout || 5000
});
} catch (error) {
console.warn(`⚠️ [ScreenshotCapture] 等待选择器超时: ${options.waitForSelector}`);
}
}
// 如果指定了元素选择器,也要等待该元素
if (options.elementSelector && options.elementSelector !== options.waitForSelector) {
try {
await page.waitForSelector(options.elementSelector, {
timeout: options.selectorTimeout || 5000
});
} catch (error) {
console.warn(`⚠️ [ScreenshotCapture] 等待目标元素超时: ${options.elementSelector}`);
}
}
// 等待字体加载完成
try {
await page.evaluate(() => {
return (document as any).fonts?.ready || Promise.resolve();
});
} catch (error) {
console.warn('⚠️ [ScreenshotCapture] 等待字体加载失败:', error);
}
// 等待所有图片加载完成
try {
await page.evaluate(() => {
return Promise.all(
Array.from(document.images).map((img: HTMLImageElement) => {
if (img.complete) {
return Promise.resolve();
}
return new Promise((resolve, reject) => {
img.addEventListener('load', resolve);
img.addEventListener('error', resolve); // 即使加载失败也继续
// 5秒超时
setTimeout(resolve, 5000);
});
})
);
});
} catch (error) {
console.warn('⚠️ [ScreenshotCapture] 等待图片加载失败:', error);
}
// 额外等待确保渲染完成
await page.waitForTimeout(1000);
}
/**
* 生成截图文件名
*/
private generateScreenshotFileName(htmlFilePath: string, workspaceRoot: string, suffix?: string): string {
const baseName = path.basename(htmlFilePath, '.html');
// 从UI设计进度.json文件中查找序号
const sequenceNumber = this.findSequenceNumberFromUIProgress(baseName, workspaceRoot);
// 如果找到序号,使用新格式:序号-html文件名.png
if (sequenceNumber !== null) {
const suffixPart = suffix ? `_${suffix}` : '';
return `${sequenceNumber}-${baseName}${suffixPart}.png`;
}
// 如果没找到序号,使用原来的格式作为fallback
const timestamp = new Date().toISOString()
.replace(/:/g, '-')
.replace(/\..+/, '')
.replace('T', '_');
const suffixPart = suffix ? `_${suffix}` : '';
return `${baseName}${suffixPart}_${timestamp}.png`;
}
/**
* 从UI设计进度.json文件中查找对应的序号
*/
private findSequenceNumberFromUIProgress(htmlFileName: string, workspaceRoot: string): number | null {
try {
// 构建UI设计进度.json文件路径
const progressFilePath = path.join(workspaceRoot, 'doc', 'UI设计进度.json');
// 检查文件是否存在
if (!fs.existsSync(progressFilePath)) {
return null;
}
// 读取文件内容
const content = fs.readFileSync(progressFilePath, 'utf8');
// 解析JSON格式
const progressData = JSON.parse(content);
// 将html文件名小写进行匹配(如adPage -> adpage)
const capitalizedPageName = htmlFileName.toLowerCase();
// 遍历所有模块和页面查找匹配
let pageIndex = 1;
if (progressData.modules && progressData.modules.length > 0) {
for (const module of progressData.modules) {
if (module.pages && module.pages.length > 0) {
for (const page of module.pages) {
const pageEnName = page.pageNameEN || page.pageNameCN;
const pageNameLower = pageEnName.toLowerCase();
// 检查页面名称是否匹配
if (pageNameLower === capitalizedPageName) {
return pageIndex;
}
pageIndex++;
}
}
}
}
return null;
} catch (error) {
console.error('❌ [ScreenshotCapture] 解析UI设计进度.json文件失败:', error);
return null;
}
}
/**
* 批量截图
*/
public async captureMultiplePages(
htmlFiles: string[],
workspaceRoot: string,
options: ScreenshotOptions = {}
): Promise<ScreenshotResult[]> {
const results: ScreenshotResult[] = [];
for (let i = 0; i < htmlFiles.length; i++) {
const htmlFile = htmlFiles[i];
const result = await this.captureHtmlPage(htmlFile, workspaceRoot, {
...options,
suffix: `batch_${i + 1}`
});
results.push(result);
// 批量截图时添加短暂延迟,避免资源占用过高
if (i < htmlFiles.length - 1) {
await new Promise(resolve => setTimeout(resolve, 100));
}
}
const successCount = results.filter(r => r.success).length;
return results;
}
/**
* 获取浏览器状态
*/
public getBrowserStatus(): BrowserStatus {
return {
isConnected: this.browser?.isConnected() || false,
isInitializing: this.isInitializing,
version: null // 移除版本信息以避免异步问题
};
}
/**
* 手动清理浏览器实例
*/
public async cleanup(): Promise<void> {
if (this.browser) {
try {
await this.browser.close();
this.browser = null;
this.isInitializing = false;
this.initPromise = null;
} catch (error) {
console.error('❌ [ScreenshotCapture] 清理浏览器实例失败:', error);
}
}
}
/**
* 静态清理方法(用于插件卸载)
*/
public static async dispose(): Promise<void> {
if (ScreenshotCapture.instance) {
await ScreenshotCapture.instance.cleanup();
ScreenshotCapture.instance = null as any;
}
}
}
/**
* 截图选项接口
*/
export interface ScreenshotOptions {
/** 视窗宽度,默认1920 */
width?: number;
/** 视窗高度,默认1080 */
height?: number;
/** 设备像素比,默认2 */
deviceScaleFactor?: number;
/** 是否截取完整页面,默认true */
fullPage?: boolean;
/** 是否透明背景,默认false */
transparent?: boolean;
/** 截图区域 */
clip?: { x: number; y: number; width: number; height: number };
/** 页面加载超时时间,默认30000ms */
timeout?: number;
/** 等待渲染完成的时间,默认1000ms */
waitTime?: number;
/** 等待特定选择器 */
waitForSelector?: string;
/** 选择器等待超时时间,默认5000ms */
selectorTimeout?: number;
/** 文件名后缀 */
suffix?: string;
/** 是否禁用JavaScript */
disableJavaScript?: boolean;
/** 是否拦截外部资源 */
blockResources?: boolean;
/** 特定元素的选择器,用于截取该元素的截图 */
elementSelector?: string;
}
/**
* 截图结果接口
*/
export interface ScreenshotResult {
/** 是否成功 */
success: boolean;
/** 截图文件绝对路径 */
screenshotPath?: string;
/** 截图文件相对路径 */
relativePath?: string;
/** 错误信息 */
error?: string;
/** 执行耗时(毫秒) */
duration: number;
}
/**
* 浏览器状态接口
*/
export interface BrowserStatus {
/** 是否已连接 */
isConnected: boolean;
/** 是否正在初始化 */
isInitializing: boolean;
/** 浏览器版本 */
version: string | null;
}
/**
* 便捷的截图函数
*/
export async function captureHtmlScreenshot(
htmlFilePath: string,
workspaceRoot: string,
options?: ScreenshotOptions
): Promise<ScreenshotResult> {
const capture = ScreenshotCapture.getInstance();
return capture.captureHtmlPage(htmlFilePath, workspaceRoot, options);
}