bytefun
Version:
一个打通了原型设计、UI设计与代码转换、跨平台原生代码开发等的平台
1,444 lines (1,084 loc) • 64.7 kB
text/typescript
import * as vscode from 'vscode';
import * as fs from 'fs';
import * as path from 'path';
import { copyImagesBySimulatingUserAction } from './copyData';
import { getNonce } from './utils';
import { WorkspaceUtils } from './workspaceUtils';
export class HtmlPreviewManager {
private htmlPreviewPanels: Map<string, vscode.WebviewPanel> = new Map();
private _extensionUri: vscode.Uri;
private _onSaveUIConfig?: (configData: string) => Promise<void>;
private _onSavePRDConfig?: (configData: string, fromWhere?: string) => Promise<void>;
private _onSaveBackendConfig?: (configData: string) => Promise<void>;
private panelFileTypes: Map<vscode.WebviewPanel, string> = new Map(); // 记录每个面板对应的文件类型
constructor(extensionUri: vscode.Uri, onSaveUIConfig?: (configData: string) => Promise<void>, onSavePRDConfig?: (configData: string, fromWhere?: string) => Promise<void>, onSaveBackendConfig?: (configData: string) => Promise<void>) {
this._extensionUri = extensionUri;
this._onSaveUIConfig = onSaveUIConfig;
this._onSavePRDConfig = onSavePRDConfig;
this._onSaveBackendConfig = onSaveBackendConfig;
}
/**
* 在浏览器中打开HTML文件
*/
public async openHtmlFileInBrowser(filePath: string, configData?: string, fromWhere?: string, uniqueKey?: string, displayTitle?: string): Promise<boolean> {
try {
const workspaceRoot = WorkspaceUtils.getProjectRootPath();
if (!workspaceRoot) {
vscode.window.showWarningMessage('无法打开文件:未找到工作区');
return false; // 返回false表示操作失败
}
let fullPath = filePath;
if (!path.isAbsolute(filePath)) {
fullPath = path.join(workspaceRoot, filePath);
}
// 生成面板的唯一键和显示标题
const panelKey = uniqueKey || fullPath;
const panelTitle = displayTitle || path.basename(fullPath);
// 如果该预览tab已经存在,则直接显示它
if (this.htmlPreviewPanels.has(panelKey)) {
const panel = this.htmlPreviewPanels.get(panelKey);
if (panel) {
panel.reveal(panel.viewColumn);
return false; // 返回false表示没有创建新tab
}
}
// 检查文件是否存在
if (!fs.existsSync(fullPath)) {
console.error(`❌ [HtmlPreviewManager] HTML文件不存在: "${fullPath}"`);
vscode.window.showWarningMessage(`HTML文件不存在: ${filePath}`);
return false; // 返回false表示操作失败
}
const panel = vscode.window.createWebviewPanel(
'bytefunHtmlPreview', // viewType
panelTitle, // title - 使用自定义标题
vscode.ViewColumn.One, // viewColumn
{
enableScripts: true,
// 授权Webview读取整个工作区的文件 (以防主HTML有其他如img的资源)
localResourceRoots: [vscode.Uri.file(workspaceRoot)],
// 保持webview状态,避免重新加载
retainContextWhenHidden: true
}
);
// 保存新创建的panel - 使用唯一键
this.htmlPreviewPanels.set(panelKey, panel);
// 记录面板对应的文件类型
const fileName = path.basename(fullPath);
this.panelFileTypes.set(panel, fileName);
// 当panel关闭时,从map中移除
panel.onDidDispose(() => {
this.htmlPreviewPanels.delete(panelKey);
this.panelFileTypes.delete(panel);
}, null, []);
// 添加消息监听器处理按钮点击事件
panel.webview.onDidReceiveMessage(
message => {
switch (message.command) {
case 'refreshPreview':
// 重新加载HTML内容
this.refreshHtmlPreview(panel, fullPath, undefined, undefined);
break;
case 'copyDesign':
this.handleCopyDesign(message.data);
break;
case 'findPageData':
this.handleFindPageData(message.pageEnName);
break;
case 'saveConfig':
this.handleSaveConfig(message.data, panel, message.fromWhere);
break;
}
},
undefined,
[]
);
this.refreshHtmlPreview(panel, fullPath, configData, fromWhere);
return true; // 返回true表示成功创建了新tab
} catch (error) {
console.error('❌ [HtmlPreviewManager] 在webview中打开HTML文件失败:', error);
vscode.window.showErrorMessage(`在webview中打开HTML文件失败: ${error}`);
return false; // 返回false表示操作失败
}
}
/**
* 处理HTML内容:修改CSS样式、追加CSS和HTML
*/
private processHtmlContent(html: string): string {
try {
let processedHtml = html;
// 1. 将CSS定义的body的h改为912px
// 更精确地匹配screen和body元素的height属性
processedHtml = processedHtml.replace(
/body\s*\{[^}]*height:\s*\d+px[^}]*\}/gi,
(match) => match.replace(/(height:\s*)\d+px/gi, '$1912px')
);
// // 2. 强制修改.page-container的padding为22px 0 22px 0
// processedHtml = processedHtml.replace(
// /\.page-container\s*\{[^}]*\}/gi,
// (match) => {
// // 检查是否已有padding属性
// if (/padding\s*:/i.test(match)) {
// // 如果已有padding,则替换
// return match.replace(/padding\s*:\s*[^;]+;?/gi, 'padding: 22px 0;');
// } else {
// // 如果没有padding,则在最后一个属性后添加
// return match.replace(/}$/, ' padding: 22px 0;\n}');
// }
// }
// );
// 3. 强制修改.dialog-overlay的CSS定义
processedHtml = processedHtml.replace(
/\.dialog-overlay\s*\{([^}]*)\}/gi,
(match, cssContent) => {
let modifiedCSS = cssContent;
// 强制替换或添加这些属性
const targetProps = {
'position': 'absolute',
'top': '0',
'left': '0',
'width': '100%',
'height': '100%',
'background': 'var(--bg-mask)',
'display': 'flex',
'justify-content': 'center',
'align-items': 'center',
'z-index': '2000'
};
// 对每个目标属性进行处理
Object.entries(targetProps).forEach(([prop, value]) => {
const propRegex = new RegExp(`\\b${prop}\\s*:\\s*[^;]+;?`, 'gi');
if (propRegex.test(modifiedCSS)) {
// 如果属性存在,替换它
modifiedCSS = modifiedCSS.replace(propRegex, `${prop}: ${value};`);
} else {
// 如果属性不存在,添加它
modifiedCSS += `\n ${prop}: ${value};`;
}
});
return `.dialog-overlay {${modifiedCSS}
}`;
}
);
// 4. 处理 vtype="dialog" 和 vtype="toast" 节点的 bottom 样式
processedHtml = this.processVtypeBottomStyle(processedHtml);
// 5. 将addCss.txt里面的css定义追加到.phone-container的后面
const additionalCss = ` .popup-container {
width: 393px;
height: 60px;
position: absolute;
bottom: 0;
left: 0;
right: 0;
background: transparent;
border: none;
display: flex;
align-items: center;
/* The following properties enable horizontal scrolling */
justify-content: flex-start;
overflow-x: auto;
flex-wrap: nowrap;
padding: 0 10px;
box-sizing: border-box;
/* Hide scrollbar for a cleaner look */
-ms-overflow-style: none;
/* IE and Edge */
scrollbar-width: none;
/* Firefox */
}
.popup-container::-webkit-scrollbar {
display: none;
/* For Chrome, Safari and Opera */
}
.popup-button {
height: 40px;
background: #292929;
border-radius: 10px;
color: #EEE;
font-size: 16px;
display: flex;
align-items: center;
justify-content: center;
padding: 0 20px;
margin: 0 10px;
cursor: pointer;
/* Add the following properties to prevent shrinking and text wrapping */
flex-shrink: 0;
white-space: nowrap;
}
.popup-button:hover {
background: #424242;
}
`;
// 查找.phone-container相关样式的结束位置,在其后面添加CSS
const phoneContainerPattern = /(\.phone-container[^}]*})/;
if (phoneContainerPattern.test(processedHtml)) {
processedHtml = processedHtml.replace(phoneContainerPattern, `$1\n\n${additionalCss}`);
}
// 6. 查找HTML中的特定vtype节点,并生成对应的控制按钮
const vtypeTargets = ['dialog', 'toast', 'dropDownMenu', 'sideSlidePanel'];
let popupButtons = '';
vtypeTargets.forEach(vtype => {
const vtypePattern = new RegExp(`<div[^>]*vtype="${vtype}"[^>]*>`, 'gi');
const matches = processedHtml.match(vtypePattern);
if (matches) {
matches.forEach((match, index) => {
// 提取name属性作为按钮文本,如果没有则使用vtype
const nameMatch = match.match(/name="([^"]*)"/);
const buttonText = nameMatch ? nameMatch[1] : vtype;
// 提取id属性,如果没有则生成一个
const idMatch = match.match(/id="([^"]*)"/);
const elementId = idMatch ? idMatch[1] : `${vtype}Element${index}`;
// 生成对应的控制按钮
const buttonId = `${elementId}ToggleBtn`;
popupButtons += ` <div name="点击切换${buttonText}显隐" id="${buttonId}" class="popup-button" vtype="button">${buttonText}</div>\n`;
});
}
});
// 7. 查找多状态容器及其子状态节点,并生成对应的控制按钮
popupButtons += this.processMultiStateContainers(processedHtml);
// 生成包含动态按钮的HTML
const additionalHtml = ` <div name="弹出类元素显示与隐藏切换容器" class="popup-container">
${popupButtons} </div>`;
// 在body节点的排在最后的孩子那里插入HTML内容
if (processedHtml.includes('</body>')) {
processedHtml = processedHtml.replace('</body>', `${additionalHtml}\n</body>`);
} else {
// 如果没有</body>标签,在HTML末尾添加
processedHtml += `\n${additionalHtml}`;
}
return processedHtml;
} catch (error) {
console.error('❌ [HtmlPreviewManager] 处理HTML内容失败:', error);
return html; // 返回原始HTML
}
}
/**
* 处理index.html内容:修改screen和body的高度
*/
private processIndexHtmlContent(html: string): string {
try {
let processedHtml = html;
// 将CSS定义的.screen类的height改为912px
processedHtml = processedHtml.replace(
/\.screen\s*\{[^}]*height:\s*\d+px[^}]*\}/gi,
(match) => match.replace(/(height:\s*)\d+px/gi, '$1912px')
);
return processedHtml;
} catch (error) {
console.error('❌ [HtmlPreviewManager] 处理index.html内容失败:', error);
return html; // 返回原始HTML
}
}
/**
* 处理 vtype="dialog" 和 vtype="toast" 节点的 bottom 样式
*/
private processVtypeBottomStyle(html: string): string {
try {
let processedHtml = html;
let hasModification = false;
// 处理 vtype="dialog" 节点
const dialogResult = this.processVtypeNodeBottomStyle(processedHtml, 'dialog', (bottomValue) => {
// dialog节点:如果有bottom: 0,改为bottom: 60px
if (bottomValue === 0) {
return 60;
}
return bottomValue; // 其他值不变
});
if (dialogResult.hasModification) {
processedHtml = dialogResult.processedHtml;
hasModification = true;
}
// 处理 vtype="toast" 节点
const toastResult = this.processVtypeNodeBottomStyle(processedHtml, 'toast', (bottomValue) => {
// toast节点:bottom值加60px
return bottomValue + 60;
});
if (toastResult.hasModification) {
processedHtml = toastResult.processedHtml;
hasModification = true;
}
if (hasModification) {
} else {
}
return processedHtml;
} catch (error) {
console.error('❌ [HtmlPreviewManager] 处理vtype节点bottom样式失败:', error);
return html; // 返回原始HTML
}
}
/**
* 为HTML内容中的所有script标签添加nonce属性以解决CSP安全问题
*/
private addNonceToScripts(html: string, nonce: string): string {
try {
let processedHtml = html;
let scriptCount = 0;
// 处理所有的<script>标签,为它们添加nonce属性
processedHtml = processedHtml.replace(/<script(\s[^>]*)?>/gi, (match, attributes) => {
scriptCount++;
// 检查是否已经有nonce属性
if (attributes && /nonce\s*=/i.test(attributes)) {
return match;
}
// 为script标签添加nonce属性
if (attributes) {
const newScript = `<script nonce="${nonce}"${attributes}>`;
return newScript;
} else {
const newScript = `<script nonce="${nonce}">`;
return newScript;
}
});
if (scriptCount > 0) {
} else {
}
return processedHtml;
} catch (error) {
console.error('❌ [HtmlPreviewManager] 为script标签添加nonce失败:', error);
return html; // 返回原始HTML
}
}
/**
* 为iframe内容添加CSP meta标签
*/
private addCSPToIframeContent(html: string, nonce: string): string {
try {
let processedHtml = html;
// 构建CSP标签,允许内联样式和带nonce的脚本
const cspTag = `<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline' https://cdnjs.cloudflare.com; script-src 'nonce-${nonce}' 'unsafe-eval'; img-src data: https: http:; font-src https://cdnjs.cloudflare.com; connect-src *;">`;
// 检查是否已经有CSP标签
if (/Content-Security-Policy/i.test(processedHtml)) {
return processedHtml;
}
// 将CSP标签添加到head中
if (processedHtml.includes('<head>')) {
processedHtml = processedHtml.replace('<head>', `<head>\n ${cspTag}`);
} else if (processedHtml.includes('<html>')) {
processedHtml = processedHtml.replace('<html>', `<html>\n<head>\n ${cspTag}\n</head>`);
} else {
processedHtml = `<head>\n ${cspTag}\n</head>\n${processedHtml}`;
}
return processedHtml;
} catch (error) {
console.error('❌ [HtmlPreviewManager] 为iframe内容添加CSP失败:', error);
return html; // 返回原始HTML
}
}
/**
* 使用cheerio将onclick等内联事件转换为script中的事件监听器
*/
private convertInlineEventsWithCheerio(htmlContent: string): string {
try {
// 动态导入cheerio
const cheerio = require('cheerio');
// 使用cheerio解析HTML
const $ = cheerio.load(htmlContent, {
xmlMode: false,
decodeEntities: false, // 保持实体编码不变
});
// 查找所有带有onclick属性的元素
const onclickElements = $('[onclick]');
if (onclickElements.length === 0) {
return htmlContent;
}
// 收集所有事件监听器代码
const eventListeners: string[] = [];
onclickElements.each((index: number, element: any) => {
const $element = $(element);
const onclickCode = $element.attr('onclick');
if (!onclickCode) return;
// 确保元素有ID,如果没有则生成一个
let elementId = $element.attr('id');
if (!elementId) {
elementId = `auto_event_${Math.random().toString(36).substr(2, 9)}`;
$element.attr('id', elementId);
}
// 生成事件监听器代码
const listenerCode = `
const element_${elementId.replace(/[^a-zA-Z0-9]/g, '_')} = document.getElementById('${elementId}');
if (element_${elementId.replace(/[^a-zA-Z0-9]/g, '_')}) {
element_${elementId.replace(/[^a-zA-Z0-9]/g, '_')}.addEventListener('click', function(event) {
${onclickCode}
});
} else {
console.warn('⚠️ [Event Converter] 未找到元素:', '${elementId}');
}`;
eventListeners.push(listenerCode);
// 移除原始的onclick属性
$element.removeAttr('onclick');
});
// 创建完整的事件监听器脚本
const eventListenerScript = `
<script>
document.addEventListener('DOMContentLoaded', function() {
${eventListeners.join('')}
});
</script>`;
// 将事件监听器脚本追加到body结束前
if ($('body').length > 0) {
$('body').append(eventListenerScript);
} else {
// 如果没有body标签,追加到html末尾
$('html').append(eventListenerScript);
}
const processedHtml = $.html();
return processedHtml;
} catch (error) {
console.error('❌ [HtmlPreviewManager] 使用cheerio转换内联事件处理器失败:', error);
return htmlContent; // 返回原始HTML
}
}
/**
* 使用 cheerio 处理多状态容器及其子状态节点,生成对应的控制按钮
*/
private processMultiStateContainers(html: string): string {
try {
// 动态导入cheerio
const cheerio = require('cheerio');
// 使用cheerio解析HTML
const $ = cheerio.load(html, {
xmlMode: false,
decodeEntities: false, // 保持实体编码不变
});
let popupButtons = '';
// 查找所有 vtype="multiStateContainer" 的节点
const $multiStateContainers = $('[vtype="multiStateContainer"]');
$multiStateContainers.each((containerIndex: number, containerElement: any) => {
const $container = $(containerElement);
// 在当前容器内查找所有 vtype="oneStateContentContainer" 的子节点
const $oneStateContainers = $container.find('[vtype="oneStateContentContainer"]');
$oneStateContainers.each((stateIndex: number, stateElement: any) => {
const $stateElement = $(stateElement);
// 提取name属性作为按钮文本
const nameAttr = $stateElement.attr('name');
const buttonText = nameAttr || `状态${stateIndex + 1}`;
// 提取id属性,如果没有则生成一个
const idAttr = $stateElement.attr('id');
const elementId = idAttr || `multiStateContent${containerIndex}_${stateIndex}`;
// 如果原来没有id,为元素添加id
if (!idAttr) {
$stateElement.attr('id', elementId);
}
// 生成对应的控制按钮
const buttonId = `${elementId}ToggleBtn`;
popupButtons += ` <div name="点击切换${buttonText}显隐" id="${buttonId}" class="popup-button" vtype="button">${buttonText}</div>\n`;
});
});
return popupButtons;
} catch (error) {
console.error('❌ [HtmlPreviewManager] 处理多状态容器失败:', error);
return '';
}
}
/**
* 处理指定vtype节点的bottom样式
*/
private processVtypeNodeBottomStyle(html: string, vtype: string, bottomValueProcessor: (bottomValue: number) => number): { processedHtml: string, hasModification: boolean } {
try {
let processedHtml = html;
let hasModification = false;
// 查找所有指定vtype的节点
const vtypeRegex = new RegExp(`<(\\w+)[^>]*vtype=["']${vtype}["'][^>]*>`, 'gi');
let match;
// 收集所有节点的class信息
const vtypeClasses: string[] = [];
while ((match = vtypeRegex.exec(html)) !== null) {
const classMatch = match[0].match(/class=["']([^"']+)["']/);
if (classMatch && classMatch[1]) {
const classNames = classMatch[1].split(/\s+/);
classNames.forEach(className => {
if (className.trim() && !vtypeClasses.includes(className.trim())) {
vtypeClasses.push(className.trim());
}
});
}
}
// 处理每个CSS类,查找并修改bottom值
for (const className of vtypeClasses) {
// 查找该class的CSS定义,检查是否有bottom属性
const classRegex = new RegExp(`\\.${className.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*\\{([^}]*bottom\\s*:\\s*\\d+(?:px)?[^}]*)\\}`, 'gi');
processedHtml = processedHtml.replace(classRegex, (match, cssContent) => {
// 提取bottom值
const bottomMatch = cssContent.match(/bottom\s*:\s*(\d+)(?:px)?/i);
if (bottomMatch) {
const currentBottomValue = parseInt(bottomMatch[1]);
const newBottomValue = bottomValueProcessor(currentBottomValue);
// 替换bottom值
const modifiedCssContent = cssContent.replace(/bottom\s*:\s*\d+(?:px)?/gi, `bottom: ${newBottomValue}px`);
hasModification = true;
return `.${className} {${modifiedCssContent}}`;
}
return match;
});
}
return { processedHtml, hasModification };
} catch (error) {
console.error(`❌ [HtmlPreviewManager] 处理${vtype}节点bottom样式失败:`, error);
return { processedHtml: html, hasModification: false };
}
}
/**
* 刷新HTML预览内容
*/
private refreshHtmlPreview(panel: vscode.WebviewPanel, fullPath: string, configData?: string, fromWhere?: string): void {
try {
let mainHtmlContent = fs.readFileSync(fullPath, 'utf8');
const fileDir = path.dirname(fullPath);
// 生成nonce用于CSP安全
const nonce = getNonce();
// 检查是否为 uiDesign.html、prdDesign.html 或 backendDesign.html
const fileName = path.basename(fullPath);
const isUiDesignFile = fileName === 'uiDesign.html';
const isPrdDesignFile = fileName === 'prdDesign.html';
const isBackendDesignFile = fileName === 'backendDesign.html';
// 定义全局缩放样式(uiDesign.html、prdDesign.html 和 backendDesign.html 不需要缩放)
const zoomStyle = (isUiDesignFile || isPrdDesignFile || isBackendDesignFile) ? '' : `<style>html { zoom: 0.6; }</style>`;
// 最终解决方案:读取iframe的src文件内容,并使用srcdoc直接内联
let finalHtml = mainHtmlContent.replace(
/<iframe\s+.*?src="([^"]*)"[^>]*>.*?<\/iframe>/gi,
(match, src) => {
// 只处理本地相对路径
if (src.startsWith('http') || src.startsWith('data:')) {
return match;
}
const iframeHtmlPath = path.resolve(fileDir, src);
if (fs.existsSync(iframeHtmlPath)) {
let iframeContent = fs.readFileSync(iframeHtmlPath, 'utf8');
// 1. 为iframe内容添加CSP安全标签
iframeContent = this.addCSPToIframeContent(iframeContent, nonce);
// 2. 使用cheerio转换内联事件处理器为script中的事件监听器
iframeContent = this.convertInlineEventsWithCheerio(iframeContent);
// 3. 为iframe内容中的所有script标签添加nonce属性
iframeContent = this.addNonceToScripts(iframeContent, nonce);
// 4. 对iframe的HTML内容进行处理:修改CSS样式、追加CSS和HTML
iframeContent = this.processHtmlContent(iframeContent);
const iframeDir = path.dirname(iframeHtmlPath);
// 将iframe中的外部CSS内联
iframeContent = iframeContent.replace(
/<link\s+.*?rel="stylesheet"\s+.*?href="([^"]*)"[^>]*>/gi,
(linkTag, href) => {
if (href.startsWith('http') || href.startsWith('//')) {
return linkTag; // 不处理外部CSS
}
const cssPath = path.resolve(iframeDir, href);
if (fs.existsSync(cssPath)) {
const cssContent = fs.readFileSync(cssPath, 'utf8');
return `<style>\n${cssContent}\n</style>`;
} else {
console.warn(`⚠️ [HtmlPreviewManager] iframe中的CSS文件未找到: "${cssPath}"`);
return `<!-- CSS not found at ${href} -->`;
}
}
);
// 将缩放样式也注入到iframe的head中
if (iframeContent.includes('<head>')) {
iframeContent = iframeContent.replace('<head>', `<head>${zoomStyle}`);
} else {
iframeContent = `<head>${zoomStyle}</head>${iframeContent}`;
}
// 使用srcdoc属性来嵌入完整的HTML内容。需要对双引号进行转义。
const escapedContent = iframeContent.replace(/"/g, '"');
// 移除原来的src属性,替换为srcdoc
const newIframe = match.replace(/src="[^"]*"/, `id="iframe-${path.basename(iframeHtmlPath)}" srcdoc="${escapedContent}"`);
return newIframe;
} else {
console.warn(`⚠️ [HtmlPreviewManager] iframe指向的文件不存在: "${iframeHtmlPath}"`);
return '<iframe><p>Error: The source file for this iframe was not found.</p></iframe>';
}
}
);
// 将缩放样式注入到主文档的head中
if (finalHtml.includes('<head>')) {
finalHtml = finalHtml.replace('<head>', `<head>${zoomStyle}`);
} else {
finalHtml = `<head>${zoomStyle}</head>` + finalHtml;
}
// 对index.html进行screen和body高度修改
finalHtml = this.processIndexHtmlContent(finalHtml);
// 在现有HTML中插入操作栏相关代码
const htmlWithToolbar = this.insertToolbarIntoHtml(finalHtml, panel, nonce, isUiDesignFile || isPrdDesignFile || isBackendDesignFile);
panel.webview.html = htmlWithToolbar;
// 如果是uiDesign.html文件且有配置数据,延迟调用JavaScript方法
if (isUiDesignFile && configData) {
setTimeout(() => {
try {
panel.webview.postMessage({
command: 'importUiDesignData',
data: configData
});
} catch (error) {
console.error('❌ [HtmlPreviewManager] 发送配置数据失败:', error);
}
}, 1000); // 延迟1秒确保页面加载完成
}
// 如果是prdDesign.html文件且有配置数据,延迟调用JavaScript方法
if (isPrdDesignFile && configData) {
setTimeout(() => {
try {
panel.webview.postMessage({
command: 'importPrdJsonData',
data: configData,
fromWhere: fromWhere || 'prd'
});
} catch (error) {
console.error('❌ [HtmlPreviewManager] 发送配置数据失败:', error);
}
}, 2000); // 延迟1秒确保页面加载完成
}
// 如果是backendDesign.html文件且有配置数据,延迟调用JavaScript方法
if (isBackendDesignFile && configData) {
setTimeout(() => {
try {
panel.webview.postMessage({
command: 'importBackendDesignData',
data: configData
});
} catch (error) {
console.error('❌ [HtmlPreviewManager] 发送后端设计配置数据失败:', error);
}
}, 1000); // 延迟1秒确保页面加载完成
}
} catch (error) {
console.error('❌ [HtmlPreviewManager] 刷新HTML预览失败:', error);
}
}
/**
* 在现有HTML中插入操作栏相关代码
*/
private insertToolbarIntoHtml(originalHtml: string, panel: vscode.WebviewPanel, nonce?: string, isUiDesignFile?: boolean): string {
// 如果是 uiDesign.html,直接返回原始HTML,不添加任何工具栏
if (isUiDesignFile) {
return originalHtml;
}
let modifiedHtml = originalHtml;
// 如果没有传入nonce,则生成一个新的
const actualNonce = nonce || getNonce();
// 1. 添加CSP meta标签到head中 - 允许外部CDN资源和内联脚本
const cspTag = `<meta http-equiv="Content-Security-Policy" content="default-src 'self' 'unsafe-inline' 'unsafe-eval' data: https: http:; style-src 'self' 'unsafe-inline' ${panel.webview.cspSource} https://cdnjs.cloudflare.com; script-src 'self' 'unsafe-inline' 'unsafe-eval' ${panel.webview.cspSource} https://cdnjs.cloudflare.com; img-src ${panel.webview.cspSource} data: https: http:; frame-src *; connect-src *; font-src https://cdnjs.cloudflare.com data:;">`;
if (modifiedHtml.includes('<head>')) {
modifiedHtml = modifiedHtml.replace('<head>', `<head>\n${cspTag}`);
} else if (modifiedHtml.includes('<html>')) {
modifiedHtml = modifiedHtml.replace('<html>', `<html>\n<head>\n${cspTag}\n</head>`);
} else {
modifiedHtml = `<head>\n${cspTag}\n</head>\n` + modifiedHtml;
}
// 2. 添加操作栏样式到head中
const toolbarStyles = `
<style id="bytefun-toolbar-styles">
/* ByteFun操作栏样式 */
.bytefun-toolbar {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 10000;
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 16px;
background: var(--vscode-sideBar-background, #252526);
border-bottom: 1px solid var(--vscode-sideBar-border, #3c3c3c);
min-height: 40px;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
.bytefun-toolbar-left {
display: flex;
align-items: center;
gap: 8px;
color: #c6c6c6;
font-size: 16px;
}
.bytefun-toolbar-right {
display: flex;
align-items: center;
gap: 8px;
}
.bytefun-toolbar-button {
background: #363636;
color: #c6c6c6;
border: none;
padding: 6px 16px;
border-radius: 3px;
cursor: pointer;
font-size: 17px;
font-weight: 500;
transition: background-color 0.2s ease;
white-space: nowrap;
}
.bytefun-toolbar-button:hover {
background: #464646;
}
.bytefun-toolbar-button:active {
transform: translateY(1px);
}
/* phone-container 中的 fix_input 样式 */
.phone-fix-input-container {
width: 350px;
margin-top: 10px;
padding: 8px;
background: var(--vscode-input-background, #3c3c3c);
border: 1px solid var(--vscode-input-border, #5a5a5a);
border-radius: 14px;
display: block;
}
.phone-fix-input {
width: 100%;
min-height: 60px;
background: transparent;
border: none;
color: var(--vscode-input-foreground, #cccccc);
font-family: monospace;
font-size: 17px;
resize: vertical;
outline: none;
font-weight: 400;
}
.phone-fix-input:focus {
outline: none;
border: none;
box-shadow: none;
}
.phone-fix-input::placeholder {
color: var(--vscode-input-placeholderForeground, #888);
}
/* 给原有内容添加顶部边距,避免被操作栏遮挡 */
body {
padding-top: 70px !important;
}
</style>`;
if (modifiedHtml.includes('</head>')) {
modifiedHtml = modifiedHtml.replace('</head>', `${toolbarStyles}\n</head>`);
} else {
modifiedHtml = toolbarStyles + '\n' + modifiedHtml;
}
// 3. 添加操作栏HTML到body开始位置
const copyButtons = isUiDesignFile ? '' : `
<button class="bytefun-toolbar-button" id="copyBtn">复制修复</button>
<button class="bytefun-toolbar-button" id="copyDesignBtn">复制设计稿</button>`;
const toolbarHtml = `
<div class="bytefun-toolbar">
<div class="bytefun-toolbar-left">
<span>🎨 HTML预览</span>
</div>
<div class="bytefun-toolbar-right">
<button class="bytefun-toolbar-button" id="refreshBtn">刷新</button>${copyButtons}
</div>
</div>`;
if (modifiedHtml.includes('<body>')) {
modifiedHtml = modifiedHtml.replace('<body>', `<body>\n${toolbarHtml}`);
} else if (modifiedHtml.includes('<body ')) {
// 处理带有属性的body标签
modifiedHtml = modifiedHtml.replace(/<body([^>]*)>/, `<body$1>\n${toolbarHtml}`);
} else {
// 如果没有body标签,在HTML开始处添加
modifiedHtml = `<body>\n${toolbarHtml}\n</body>\n` + modifiedHtml;
}
// 为每个phone-frame-container的page-title后面添加fix_input输入框
let phoneContainerIndex = 0;
modifiedHtml = modifiedHtml.replace(/class="phone-frame-container"/g, (match) => {
phoneContainerIndex++;
const fixInputHtml = `
<div class="phone-fix-input-container" id="phoneFixInput${phoneContainerIndex}">
<textarea class="phone-fix-input" placeholder="在此输入修改描述..."></textarea>
</div>`;
return match; // 先返回原匹配,然后在page-title后插入
});
// 重置计数器,然后在每个page-title后插入输入框
phoneContainerIndex = 0;
modifiedHtml = modifiedHtml.replace(/<div class="page-title">[\s\S]*?<\/div>/g, (match) => {
phoneContainerIndex++;
const fixInputHtml = `
<div class="phone-fix-input-container" id="phoneFixInput${phoneContainerIndex}">
<textarea class="phone-fix-input" placeholder="在此输入修改描述..."></textarea>
</div>`;
return match + fixInputHtml;
});
// 4. 添加内联脚本到body结束前
const inlineScript = `<script nonce="${actualNonce}">
const vscode = acquireVsCodeApi();
// 页面加载时初始化
document.addEventListener('DOMContentLoaded', function () {
// 初始化事件监听器
initializeEventListeners();
});
// 初始化所有事件监听器
function initializeEventListeners() {
// 清理之前的事件监听器(如果存在)
cleanupEventListeners();
// 刷新预览按钮
const refreshBtn = document.getElementById('refreshBtn');
if (refreshBtn) {
refreshBtn.addEventListener('click', handleRefreshPreview);
} else {
}
// 复制修复输入按钮
const copyBtn = document.getElementById('copyBtn');
if (copyBtn) {
copyBtn.addEventListener('click', handleCopyFixInput);
} else {
}
// 复制设计稿按钮
const copyDesignBtn = document.getElementById('copyDesignBtn');
if (copyDesignBtn) {
copyDesignBtn.addEventListener('click', clickCopyDesign);
} else {
}
// 编辑按钮事件监听器
const editButtons = document.querySelectorAll('.edit-btn');
editButtons.forEach(button => {
button.addEventListener('click', handleEditButtonClick);
});
// 监听iframe的load事件
const iframes = document.querySelectorAll('iframe');
iframes.forEach(iframe => {
iframe.addEventListener('load', (event) => {
const iframeContentWindow = event.target.contentWindow;
if (iframeContentWindow) {
// 通用的UI元素处理函数
const processUIElements = (vtype, displayStyle, autoHide, autoHideDelay) => {
const regex = new RegExp('<div[^>]*vtype="' + vtype + '"[^>]*>', 'g');
const matches = bodyHTML.match(regex);
if (matches && matches.length > 0) {
matches.forEach(match => {
const idMatch = match.match(/id="([^"]+)"/);
if (idMatch) {
const elementId = idMatch[1];
const toggleBtn = iframeContentWindow.document.getElementById(elementId+'ToggleBtn');
if (toggleBtn) {
toggleBtn.addEventListener('click', () => {
const element = iframeContentWindow.document.getElementById(elementId);
if (element) {
if (element.style.display === 'none') {
element.style.display = displayStyle;
// 如果需要自动隐藏
if (autoHide) {
setTimeout(() => {
element.style.display = 'none';
}, autoHideDelay);
}
} else {
element.style.display = 'none';
}
}
});
}
}
});
} else {
}
};
// 查找并处理各种UI元素
const bodyHTML = iframeContentWindow.document.body.innerHTML;
// 处理各种类型的UI元素
processUIElements('dialog', 'flex', false, 0); // 对话框:flex显示,不自动隐藏
processUIElements('toast', 'flex', true, 3000); // Toast:flex显示,3秒自动隐藏
processUIElements('dropDownMenu', 'block', false, 0); // 下拉菜单:block显示,不自动隐藏
processUIElements('sideSlidePanel', 'block', false, 0); // 侧滑面板:block显示,不自动隐藏
// 处理多状态容器的状态互斥逻辑
processMultiStateContainers();
// 多状态容器处理函数
function processMultiStateContainers() {
// 查找所有多状态容器
const multiStateContainerRegex = /<div[^>]*vtype="multiStateContainer"[^>]*>/g;
const multiStateMatches = bodyHTML.match(multiStateContainerRegex);
if (!multiStateMatches || multiStateMatches.length === 0) {
return;
}
multiStateMatches.forEach((containerMatch, containerIndex) => {
// 提取容器的id属性
const containerIdMatch = containerMatch.match(/id="([^"]+)"/);
if (!containerIdMatch) {
console.warn('⚠️ [HTML Preview] 多状态容器没有id,跳过处理');
return;
}
const containerId = containerIdMatch[1];
// 在iframe中找到这个容器元素
const containerElement = iframeContentWindow.document.getElementById(containerId);
if (!containerElement) {
console.warn('⚠️ [HTML Preview] 在iframe中未找到多状态容器元素:', containerId);
return;
}
// 在容器内查找所有状态元素
const stateElements = containerElement.querySelectorAll('[vtype="oneStateContentContainer"]');
if (stateElements.length === 0) {
console.warn('⚠️ [HTML Preview] 容器', containerId, '中没有状态元素');
return;
}
// 为每个状态元素添加按钮点击事件
stateElements.forEach((stateElement, stateIndex) => {
const stateElementId = stateElement.id;
if (!stateElementId) {
console.warn('⚠️ [HTML Preview] 状态元素没有id,跳过处理');
return;
}
// 查找对应的按钮
const toggleBtnId = stateElementId + 'ToggleBtn';
const toggleBtn = iframeContentWindow.document.getElementById(toggleBtnId);
if (!toggleBtn) {
console.warn('⚠️ [HTML Preview] 未找到状态按钮:', toggleBtnId);
return;
}
// 添加点击事件监听器
toggleBtn.addEventListener('click', () => {
// 执行状态互斥切换
stateElements.forEach((otherStateElement) => {
if (otherStateElement.id === stateElementId) {
otherStateElement.style.opacity = '1';
} else {
otherStateElement.style.opacity = '0';
}
});
});
});
// 初始化:根据active类设置opacity状态
if (stateElements.length > 0) {
stateElements.forEach((stateElement) => {
const hasActiveClass = stateElement.classList.contains('active');
if (hasActiveClass) {
stateElement.style.opacity = '1';
} else {
stateElement.style.opacity = '0';
}
});
}
});
}
}
});
});
// 监听来自VS Code的消息
window.addEventListener('message', handleVSCodeMessage);
}
// 清理事件监听器
function cleanupEventListeners() {
// 移除按钮事件监听器(通过克隆节点的方式移除所有事件监听器)
const refreshBtn = document.getElementById('refreshBtn');
if (refreshBtn) {
const newRefreshBtn = refreshBtn.cloneNode(true);
refreshBtn.parentNode?.replaceChild(newRefreshBtn, refreshBtn);
}
const copyBtn = document.getElementById('copyBtn');
if (copyBtn) {
const newCopyBtn = copyBtn.cloneNode(true);
copyBtn.parentNode?.replaceChild(newCopyBtn, copyBtn);
}
const copyDesignBtn = document.getElementById('copyDesignBtn');
if (copyDesignBtn) {
const newCopyDesignBtn = copyDesignBtn.cloneNode(true);
copyDesignBtn.parentNode?.replaceChild(newCopyDesignBtn, copyDesignBtn);
}
}
// 处理刷新预览
function handleRefreshPreview() {
vscode.postMessage({ command: 'refreshPreview' });
}
// 处理复制修复输入
function handleCopyFixInput() {
// 收集所有phone-frame-container中的修改描述
const phoneContainers = document.querySelectorAll('.phone-frame-container');
const modifications = [];
phoneContainers.forEach((container, index) => {
// 获取页面标题
const pageTitleElement = container.querySelector('.page-title');
const pageTitle = pageTitleElement ? pageTitleElement.textContent.trim() : \`页面\${index + 1}\`;
// 获取对应的fix-input内容
const fixInputElement = container.querySelector('.phone-fix-input');
const fixInputContent = fixInputElement ? fixInputElement.value.trim() : '';
// 如果有输入内容,则添加到修改列表
if (fixInputContent) {
// 提取页面标题,去掉序号和点号,保留主要描述
const cleanTitle = pageTitle.replace(/^\d+\.\s*/, '').trim();
modifications.push(\`\${index + 1}、\${cleanTitle}的修改:\${fixInputContent}\`);
}
});
if (modifications.length > 0) {
// 拼接所有修改描述
const allModifications = modifications.join('。 ');
// 复制到剪贴板
navigator.clipboard.writeText(allModifications).then(() => {
const copyBtn = document.getElementById('copyBtn');
if (copyBtn) {
const originalText = copyBtn.textContent;
copyBtn.textContent = '已复制';
setTimeout(() => {
copyBtn.textContent = originalText;
}, 1500);
}
}).catch(err => {
console.error('❌ [HTML Preview] 复制失败:', err);
});
} else {
}
}
// 处理编辑按钮点击事件
function handleEditButtonClick(event) {
const pageEnName = event.target.id;
try {
// 发送消息到VSCode扩展,请求查找页面数据
vscode.postMessage({
command: 'findPageData',
pageEnName: pageEnName
});
} catch (error) {
console.error('❌ [HTML Preview] 发送消息到VSCode扩展失败:', error);
}
}
// 处理来自VS Code的消息
function handleVSCodeMessage(event) {
const message = event.data;
switch (message.command) {
case 'copyToClipboard':
// 复制功能已在handleCopyFixInput中处理
break;
case 'refreshContent':
// 可以在这里处理内容刷新的额外逻辑
break;
default:
}
}
// 处理复制设计稿按钮点击事件
function clickCopyDesign() {
// 获取所有有输入内容的fix-input对应的页面英文名
const pageEnNames = [];
const phoneContainers = document.querySelectorAll('.phone-frame-container');
phoneContainers.forEach((container, index) => {
const fixInputElement = container.querySelector('.phone-fix-input');
const pageTitleElement = container.querySelector('.page-title');
if (fixInputElement && pageTitleElement) {
const inputValue = fixInputElement.value.trim();
// 只有输入框有内容时才查找button元素
if (inputValue.length > 0) {
// 在page-title节点中查找button子节点
const buttonElement = pageTitleElement.querySelector('button');
if (buttonElement && buttonElement.id) {
const pageEnName = buttonElement.id;
pageEnNames.push(pageEnName);
} else {
}
} else {
}
} else {
}
});
if (pageEnNames.length === 0) {
vscode.postMessage({
command: 'copyDesign',
data: {
pageEnNames: [],
message: '请先输入修改描述,再点击复制设计稿'
}
});
return;
}
vscode.postMessage({
command: 'copyDesign',
data: {
pageEnNames: pageEnNames
}
});
}
// 暴露给全局的方法,供调试使用
window.reinitializeEventListeners = function () {
initializeEventListeners();
};
// 立即尝试初始化(防止DOMContent