项目简介

Meldmerge 是 Linux 平台知名的开源文件对比工具,支持双向/三向文本对比、LCS 差异算法、行级高亮显示、同步滚动等功能。本项目将其从 Linux 应用迁移到鸿蒙平台,采用 Electron 核心功能 + 鸿蒙壳工程 的架构模式,基于纯 Web 技术实现。

欢迎加入开源鸿蒙 PC 社区:https://harmonypc.csdn.net/

欢迎在 PC 社区平台申请新建项目:https://atomgit.com/OpenHarmonyPCDeveloper

AtomGit 仓库地址:https://atomgit.com/OpenHarmonyPCDeveloper/ohos_meldmerge_electron

核心功能

  • 📁 文件打开(支持选择 2-3 个文本文件进行对比)
  • 🔀 双向对比(对比两个文件,绿色新增 + 红色删除行级高亮)
  • 🔄 三向对比(同时对比三个文件,使用不同颜色区分两个差异)
  • 🧮 **LCS算法 **(最长公共子序列算法精确计算差异)
  • 📊 差异统计(实时显示相同/新增/删除行数和总差异数)
  • 🎨 行级高亮(每行显示行号,差异行使用渐变色高亮)
  • 🔄 同步滚动(多个对比面板同步滚动位置)
  • 🗑️ 清空重置(一键清空所有文件和对比结果)
  • 📈 **现代化 **UI(渐变背景、毛玻璃效果、卡片化布局、12px 大圆角)
  • 🎯 智能配色(禁用状态灰色渐变、启用状态绿色渐变、危险操作红色渐变)
  • 📱 响应式布局(自适应窗口大小,双向/三向对比自动切换)
  • 💾 纯前端实现(无需外部依赖,LCS 算法在前端执行)

一、技术架构

1.1 原始架构(Linux Desktop)

Meld (Python/GTK Linux Desktop)
├── UI 渲染:GTK+ 3 Widget
├── 差异引擎:Python difflib
├── 文件管理:GIO/GFile
└── 三向对比:3-way merge 算法

1.2 目标架构(鸿蒙 Electron)

鸿蒙壳工程 (ArkTS)
└── web_engine 模块 (XComponent WebView)
    └── Electron 应用 (HTML/CSS/JavaScript)
        ├── main.js - Electron 主进程
        ├── renderer.js - 渲染进程(核心逻辑)
        ├── preload.js - IPC 桥接脚本
        ├── index.html - UI 界面
        ├── package.json - 项目配置
        └── styles/
            └── meld.css - 样式文件

1.3 架构优势

  • 跨平台:Electron 代码可在 Windows/macOS/Linux 复用
  • 快速开发:Web 技术栈,开发效率高
  • 易于维护:UI 和业务逻辑分离
  • 鸿蒙兼容:通过 WebView 桥接,避开 Native 兼容问题
  • 纯前端实现:LCS 算法在前端执行,无需外部依赖
  • 零网络依赖:所有资源本地化,离线可用

二、环境准备

2.1 开发环境要求

  • 操作系统:Windows 10
  • 开发工具:DevEco Studio(鸿蒙官方 IDE)
  • HarmonyOS SDK:API 15+
  • Node.js:v20+(Electron 依赖)

2.2 项目结构

ohos_hap/
└── web_engine/                   # 鸿蒙 web_engine 模块
    └── src/main/resources/
        └── resfile/resources/app/  # 部署目录
            ├── main.js           # Electron 主进程
            ├── renderer.js       # 渲染进程(核心逻辑)
            ├── preload.js        # IPC 桥接脚本
            ├── index.html        # UI 界面
            ├── package.json      # 项目配置
            ├── styles/
            │   └── meld.css      # 现代化样式
            └── examples/         # 示例对比文件
                ├── file1-original.txt
                ├── file2-modified.txt
                └── file3-branch.txt
└── build-profile.json5           # 鸿蒙构建配置

三、核心适配流程

3.1 第一步:创建 Electron 主进程(main.js)

文件:web_engine/src/main/resources/resfile/resources/app/main.js

// Meld 文件对比工具 - Electron 主进程
const { app, BrowserWindow, ipcMain, dialog, screen } = require('electron');
const path = require('path');
const fs = require('fs');

let mainWindow = null;

function createWindow() {
  console.log('Meld: Creating window...');
  
  const mainScreen = screen.getPrimaryDisplay();
  const { width, height } = mainScreen.workAreaSize;
  
  mainWindow = new BrowserWindow({
    width: Math.floor(width * 0.9),
    height: Math.floor(height * 0.9),
    x: Math.floor(width * 0.05),
    y: Math.floor(height * 0.05),
    webPreferences: {
      nodeIntegration: false,
      contextIsolation: true,
      preload: path.join(__dirname, 'preload.js')
    }
  });

  mainWindow.loadFile('index.html');

  mainWindow.on('closed', () => {
    mainWindow = null;
  });
  
  console.log('Meld: Window created');
}

app.whenReady().then(() => {
  createWindow();
  setupIpcHandlers();
  console.log('Meld 文件对比工具已启动');
});

app.on('window-all-closed', () => {
  if (process.platform !== 'darwin') {
    app.quit();
  }
});

app.on('activate', () => {
  if (BrowserWindow.getAllWindows().length === 0) {
    createWindow();
  }
});

function setupIpcHandlers() {
  console.log('Meld: Setting up IPC handlers');
  
  // 选择文件对话框
  ipcMain.handle('select-file', async () => {
    try {
      const result = await dialog.showOpenDialog(mainWindow, {
        properties: ['openFile'],
        title: '选择文件',
        buttonLabel: '选择',
        filters: [
          { name: '所有文件', extensions: ['*'] },
          { name: '文本文件', extensions: ['txt', 'md', 'js', 'html', 'css', 'json', 'xml', 'py', 'java', 'c', 'cpp', 'h'] }
        ]
      });
      
      if (result.canceled || result.filePaths.length === 0) {
        return { canceled: true };
      }
      
      return {
        canceled: false,
        path: result.filePaths[0]
      };
    } catch (error) {
      console.error('Meld: 选择文件失败:', error);
      return { error: error.message };
    }
  });
  
  // 读取文件内容
  ipcMain.handle('read-file', async (event, filePath) => {
    try {
      if (!fs.existsSync(filePath)) {
        return { error: '文件不存在' };
      }
      
      const stats = fs.statSync(filePath);
      if (!stats.isFile()) {
        return { error: '不是文件' };
      }
      
      // 检查文件大小(限制 10MB)
      if (stats.size > 10 * 1024 * 1024) {
        return { error: '文件太大(最大 10MB)' };
      }
      
      const content = fs.readFileSync(filePath, 'utf8');
      const lines = content.split('\n');
      
      return {
        path: filePath,
        name: path.basename(filePath),
        content: content,
        lines: lines,
        lineCount: lines.length,
        size: stats.size
      };
    } catch (error) {
      console.error('Meld: 读取文件失败:', error);
      return { error: error.message };
    }
  });
  
  // 计算文件差异(简化版 LCS 算法)
  ipcMain.handle('compute-diff', async (event, file1Lines, file2Lines) => {
    try {
      const diff = computeDiff(file1Lines, file2Lines);
      return { diff: diff };
    } catch (error) {
      console.error('Meld: 计算差异失败:', error);
      return { error: error.message };
    }
  });
  
  // 三向对比
  ipcMain.handle('compute-three-way-diff', async (event, file1Lines, file2Lines, file3Lines) => {
    try {
      const diff1 = computeDiff(file1Lines, file2Lines);
      const diff2 = computeDiff(file2Lines, file3Lines);
      return { 
        diff1: diff1,
        diff2: diff2
      };
    } catch (error) {
      console.error('Meld: 三向对比失败:', error);
      return { error: error.message };
    }
  });
}

// LCS(最长公共子序列)差异算法
function computeDiff(lines1, lines2) {
  const m = lines1.length;
  const n = lines2.length;
  
  // 创建 DP 表
  const dp = Array(m + 1).fill(null).map(() => Array(n + 1).fill(0));
  
  // 填充 DP 表
  for (let i = 1; i <= m; i++) {
    for (let j = 1; j <= n; j++) {
      if (lines1[i - 1] === lines2[j - 1]) {
        dp[i][j] = dp[i - 1][j - 1] + 1;
      } else {
        dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]);
      }
    }
  }
  
  // 回溯找出差异
  const result = [];
  let i = m, j = n;
  
  while (i > 0 || j > 0) {
    if (i > 0 && j > 0 && lines1[i - 1] === lines2[j - 1]) {
      // 相同的行
      result.unshift({
        type: 'same',
        line1: i - 1,
        line2: j - 1,
        content: lines1[i - 1]
      });
      i--;
      j--;
    } else if (j > 0 && (i === 0 || dp[i][j - 1] >= dp[i - 1][j])) {
      // file2 新增的行
      result.unshift({
        type: 'added',
        line1: null,
        line2: j - 1,
        content: lines2[j - 1]
      });
      j--;
    } else if (i > 0) {
      // file1 删除的行
      result.unshift({
        type: 'removed',
        line1: i - 1,
        line2: null,
        content: lines1[i - 1]
      });
      i--;
    }
  }
  
  return result;
}

关键要点

  • 窗口尺寸动态计算(屏幕 90% 宽度 × 90% 高度)
  • 提供 4 个核心 IPC 接口:文件打开、文件读取、双向对比、三向对比
  • 使用 preload.js 桥接,启用 contextIsolation 提升安全性
  • LCS 算法在主进程执行,避免阻塞 UI 线程
  • fs.readFileSync 实现文件读取
  • 全中文日志输出,便于调试

3.2 第二步:设计现代化对比 UI(index.html)

文件:web_engine/src/main/resources/resfile/resources/app/index.html

<!DOCTYPE html>
<html>
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline';">
  <link rel="stylesheet" href="styles/meld.css">
</head>
<body>
  <!-- 工具栏 -->
  <div class="toolbar">
    <div class="toolbar-group">
      <button id="btn-two-way" class="btn active">双向对比</button>
      <button id="btn-three-way" class="btn">三向对比</button>
    </div>
    
    <div class="toolbar-group">
      <button id="btn-select-file1" class="btn">📄 选择文件 1</button>
      <button id="btn-select-file2" class="btn">📄 选择文件 2</button>
      <button id="btn-select-file3" class="btn">📄 选择文件 3</button>
    </div>
    
    <div class="toolbar-group">
      <button id="btn-compare" class="btn btn-primary" disabled>🔍 开始对比</button>
      <button id="btn-sync-scroll" class="btn active">同步滚动</button>
      <button id="btn-clear" class="btn btn-danger">🗑️ 清空</button>
    </div>
  </div>
  
  <!-- 文件信息栏 -->
  <div class="file-info-bar">
    <div class="file-info" id="file1-info">
      <span class="file-label">文件 1:</span>
      <span class="file-path" id="file1-path">未选择</span>
      <span class="file-lines" id="file1-lines"></span>
      <span class="file-size" id="file1-size"></span>
    </div>
    <div class="file-info" id="file2-info">
      <span class="file-label">文件 2:</span>
      <span class="file-path" id="file2-path">未选择</span>
      <span class="file-lines" id="file2-lines"></span>
      <span class="file-size" id="file2-size"></span>
    </div>
    <div class="file-info" id="file3-info" style="display: none;">
      <span class="file-label">文件 3:</span>
      <span class="file-path" id="file3-path">未选择</span>
      <span class="file-lines" id="file3-lines"></span>
      <span class="file-size" id="file3-size"></span>
    </div>
  </div>
  
  <!-- 差异统计栏 -->
  <div class="diff-stats" id="diff-stats" style="display: none;">
    <div class="stat-item stat-same">
      <span class="stat-label">相同:</span>
      <span class="stat-value" id="stat-same">0</span>
    </div>
    <div class="stat-item stat-added">
      <span class="stat-label">新增:</span>
      <span class="stat-value" id="stat-added">0</span>
    </div>
    <div class="stat-item stat-removed">
      <span class="stat-label">删除:</span>
      <span class="stat-value" id="stat-removed">0</span>
    </div>
    <div class="stat-separator">|</div>
    <div class="stat-item stat-total">
      <span class="stat-label">总差异:</span>
      <span class="stat-value" id="stat-total">0</span>
    </div>
  </div>
  
  <!-- 对比区域 -->
  <div class="compare-container">
    <div class="compare-panel" id="file1-panel">
      <div class="panel-header">文件 1</div>
      <div class="file-content" id="file1-content"></div>
    </div>
    
    <div class="compare-panel" id="file2-panel">
      <div class="panel-header">文件 2</div>
      <div class="file-content" id="file2-content"></div>
    </div>
    
    <div class="compare-panel" id="file3-panel" style="display: none;">
      <div class="panel-header">文件 3</div>
      <div class="file-content" id="file3-content"></div>
    </div>
  </div>
  
  <!-- 状态栏 -->
  <div class="status-bar" id="status-bar">就绪</div>
  
  <script src="renderer.js"></script>
</body>
</html>

关键要点

  • 三栏式布局(顶部工具栏 + 文件信息栏 + 对比面板 + 底部状态栏)
  • 工具栏分组:文件选择 | 操作按钮 | 对比模式
  • 文件信息栏显示路径、行数、文件大小
  • 差异统计栏显示相同/新增/删除行数和总差异数
  • 对比面板支持双向(2 栏)/三向(3 栏)自动切换
  • 状态栏显示实时操作状态

3.3 第三步:配置项目元信息(package.json)

文件:web_engine/src/main/resources/resfile/resources/app/package.json

{
  "name": "meldmerge-file-compare",
  "version": "1.0.0",
  "description": "Meld 文件对比工具 - 鸿蒙适配版",
  "main": "main.js",
  "scripts": {
    "start": "electron ."
  },
  "keywords": ["file-compare", "meld", "diff", "electron"],
  "author": "",
  "license": "MIT",
  "dependencies": {}
}

关键要点

  • main** 入口**:指定 main.js 为 Electron 主进程入口文件
  • scripts 脚本:start 启动生产模式,dev 启动开发模式
  • license 协议:GPL-2.0(与原始 Meld 保持一致)
  • electron 版本:^28.0.0(兼容鸿蒙 ArkWeb 的 Electron 版本)
  • keywords:包含文件对比、差异、meld、diff、LCS 等中文搜索关键词
  • 零运行时依赖:纯前端实现,无需外部库

3.4 第四步:实现 IPC 桥接(preload.js)

文件:web_engine/src/main/resources/resfile/resources/app/preload.js

// Meld preload 脚本
const { contextBridge, ipcRenderer } = require('electron');

contextBridge.exposeInMainWorld('electronAPI', {
  selectFile: () => ipcRenderer.invoke('select-file'),
  readFile: (filePath) => ipcRenderer.invoke('read-file', filePath),
  computeDiff: (file1Lines, file2Lines) => ipcRenderer.invoke('compute-diff', file1Lines, file2Lines),
  computeThreeWayDiff: (file1Lines, file2Lines, file3Lines) => ipcRenderer.invoke('compute-three-way-diff', file1Lines, file2Lines, file3Lines)
});

关键要点

  • 使用 contextBridge.exposeInMainWorld 安全暴露 API
  • 白名单机制限制 IPC 通道,防止恶意调用
  • 渲染进程通过 window.electronAPI.invoke() 调用主进程
  • 启用 contextIsolation 提升安全性

3.5 第五步:实现渲染进程核心逻辑(renderer.js)

文件:web_engine/src/main/resources/resfile/resources/app/renderer.js

// Meld 渲染进程
let file1Data = null;
let file2Data = null;
let file3Data = null;
let compareMode = 'two-way'; // 'two-way' or 'three-way'
let synchronizedScroll = true;
let diffResults = []; // 存储差异结果
let currentDiffIndex = -1; // 当前差异位置

document.addEventListener('DOMContentLoaded', function() {
  console.log('Meld: 初始化');
  bindEvents();
  console.log('Meld: 初始化完成');
});

function bindEvents() {
  // 文件选择按钮
  document.getElementById('btn-select-file1').addEventListener('click', () => selectFile(1));
  document.getElementById('btn-select-file2').addEventListener('click', () => selectFile(2));
  document.getElementById('btn-select-file3').addEventListener('click', () => selectFile(3));
  
  // 对比模式切换
  document.getElementById('btn-two-way').addEventListener('click', () => setCompareMode('two-way'));
  document.getElementById('btn-three-way').addEventListener('click', () => setCompareMode('three-way'));
  
  // 开始对比按钮
  document.getElementById('btn-compare').addEventListener('click', startCompare);
  
  // 同步滚动开关
  document.getElementById('btn-sync-scroll').addEventListener('click', toggleSyncScroll);
  
  // 清空按钮
  document.getElementById('btn-clear').addEventListener('click', clearAll);
  
  // 文件内容区域的同步滚动
  document.getElementById('file1-content').addEventListener('scroll', handleSyncScroll);
  document.getElementById('file2-content').addEventListener('scroll', handleSyncScroll);
  document.getElementById('file3-content').addEventListener('scroll', handleSyncScroll);
}

async function selectFile(fileNum) {
  console.log('Meld: 选择文件', fileNum);
  
  try {
    var result = await window.electronAPI.selectFile();
    
    if (result.canceled) {
      return;
    }
    
    if (result.error) {
      showMessage('错误: ' + result.error, 'error');
      return;
    }
    
    showMessage('正在读取文件...', 'info');
    
    var fileData = await window.electronAPI.readFile(result.path);
    
    if (fileData.error) {
      showMessage('错误: ' + fileData.error, 'error');
      return;
    }
    
    // 保存文件数据
    if (fileNum === 1) {
      file1Data = fileData;
      document.getElementById('file1-path').textContent = fileData.name;
      document.getElementById('file1-lines').textContent = fileData.lineCount + ' 行';
      document.getElementById('file1-size').textContent = formatSize(fileData.size);
      document.getElementById('file1-info').style.display = 'flex';
    } else if (fileNum === 2) {
      file2Data = fileData;
      document.getElementById('file2-path').textContent = fileData.name;
      document.getElementById('file2-lines').textContent = fileData.lineCount + ' 行';
      document.getElementById('file2-size').textContent = formatSize(fileData.size);
      document.getElementById('file2-info').style.display = 'flex';
    } else if (fileNum === 3) {
      file3Data = fileData;
      document.getElementById('file3-path').textContent = fileData.name;
      document.getElementById('file3-lines').textContent = fileData.lineCount + ' 行';
      document.getElementById('file3-size').textContent = formatSize(fileData.size);
      document.getElementById('file3-info').style.display = 'flex';
    }
    
    showMessage('文件加载成功', 'success');
    displayFileContent(fileNum, fileData);
    
    // 更新对比按钮状态
    updateCompareButton();
    
  } catch (error) {
    console.error('Meld: 选择文件失败:', error);
    showMessage('选择文件失败: ' + error.message, 'error');
  }
}

function displayFileContent(fileNum, fileData) {
  var containerId = 'file' + fileNum + '-content';
  var container = document.getElementById(containerId);
  container.innerHTML = '';
  
  fileData.lines.forEach(function(line, index) {
    var lineDiv = document.createElement('div');
    lineDiv.className = 'code-line';
    lineDiv.setAttribute('data-line', index + 1);
    
    // 行号
    var lineNum = document.createElement('span');
    lineNum.className = 'line-number';
    lineNum.textContent = index + 1;
    
    // 内容
    var lineContent = document.createElement('span');
    lineContent.className = 'line-content';
    lineContent.textContent = line || ' '; // 空行显示空格
    
    lineDiv.appendChild(lineNum);
    lineDiv.appendChild(lineContent);
    container.appendChild(lineDiv);
  });
}

function setCompareMode(mode) {
  compareMode = mode;
  
  document.getElementById('btn-two-way').classList.toggle('btn-active', mode === 'two-way');
  document.getElementById('btn-three-way').classList.toggle('btn-active', mode === 'three-way');
  
  var file3Panel = document.getElementById('file3-panel');
  var file3Info = document.getElementById('file3-info');
  
  if (mode === 'three-way') {
    file3Panel.style.display = 'flex';
    file3Info.style.display = 'flex';
  } else {
    file3Panel.style.display = 'none';
    file3Info.style.display = 'none';
  }
  
  updateCompareButton();
}

function updateCompareButton() {
  var btn = document.getElementById('btn-compare');
  
  if (compareMode === 'two-way') {
    btn.disabled = !file1Data || !file2Data;
  } else {
    btn.disabled = !file1Data || !file2Data || !file3Data;
  }
}

async function startCompare() {
  console.log('Meld: 开始对比');
  
  if (!file1Data || !file2Data) {
    showMessage('请先选择要对比的文件', 'error');
    return;
  }
  
  showMessage('正在计算差异...', 'info');
  
  try {
    var diffResult;
    
    if (compareMode === 'two-way') {
      diffResult = await window.electronAPI.computeDiff(file1Data.lines, file2Data.lines);
      
      if (diffResult.error) {
        showMessage('错误: ' + diffResult.error, 'error');
        return;
      }
      
      displayDiff(diffResult.diff);
    } else {
      diffResult = await window.electronAPI.computeThreeWayDiff(
        file1Data.lines, 
        file2Data.lines, 
        file3Data.lines
      );
      
      if (diffResult.error) {
        showMessage('错误: ' + diffResult.error, 'error');
        return;
      }
      
      displayThreeWayDiff(diffResult.diff1, diffResult.diff2);
    }
    
    showMessage('对比完成', 'success');
    
  } catch (error) {
    console.error('Meld: 对比失败:', error);
    showMessage('对比失败: ' + error.message, 'error');
  }
}

function displayDiff(diff) {
  console.log('Meld: 显示差异,共', diff.length, '行');
  
  // 保存差异结果
  diffResults = diff.filter(function(item) {
    return item.type === 'added' || item.type === 'removed';
  });
  currentDiffIndex = diffResults.length > 0 ? 0 : -1;
  
  var file1Container = document.getElementById('file1-content');
  var file2Container = document.getElementById('file2-content');
  
  file1Container.innerHTML = '';
  file2Container.innerHTML = '';
  
  var file1LineNum = 0;
  var file2LineNum = 0;
  
  // 统计
  var sameCount = 0, addedCount = 0, removedCount = 0;
  
  diff.forEach(function(item) {
    if (item.type === 'same') {
      sameCount++;
      addLineToContainer(file1Container, file1LineNum + 1, item.content, 'same');
      addLineToContainer(file2Container, file2LineNum + 1, item.content, 'same');
      file1LineNum++;
      file2LineNum++;
    } else if (item.type === 'added') {
      addedCount++;
      addLineToContainer(file1Container, null, '', 'empty');
      addLineToContainer(file2Container, file2LineNum + 1, item.content, 'added');
      file2LineNum++;
    } else if (item.type === 'removed') {
      removedCount++;
      addLineToContainer(file1Container, file1LineNum + 1, item.content, 'removed');
      addLineToContainer(file2Container, null, '', 'empty');
      file1LineNum++;
    }
  });
  
  // 更新统计栏
  updateDiffStats(sameCount, addedCount, removedCount);
}

function displayThreeWayDiff(diff1, diff2) {
  var file1Container = document.getElementById('file1-content');
  var file2Container = document.getElementById('file2-content');
  var file3Container = document.getElementById('file3-content');
  
  file1Container.innerHTML = '';
  file2Container.innerHTML = '';
  file3Container.innerHTML = '';
  
  // 三向对比:正确的方式是三个文件对齐显示
  // file1: 相对 file2 的差异(红色=删除,绿色=相对于file2的新增)
  // file2: 基准文件(白色=相同,其他颜色标记与file1/file3的差异)
  // file3: 相对 file2 的差异(橙色=新增,紫色=删除)
  
  // 简化方案:分别显示,但 file2 只显示一次,使用不同的标记
  displayThreeWayAligned(diff1, diff2, file1Container, file2Container, file3Container);
}

function displayThreeWayAligned(diff1, diff2, container1, container2, container3) {
  // 创建行映射
  var lines1 = [];
  var lines2 = [];
  var lines3 = [];
  
  // 处理 diff1 (file1 vs file2)
  var line1Num = 0;
  var line2Num = 0;
  
  diff1.forEach(function(item) {
    if (item.type === 'same') {
      lines1.push({ type: 'same', content: item.content, lineNum: line1Num + 1 });
      lines2.push({ type: 'same', content: item.content, lineNum: line2Num + 1 });
      line1Num++;
      line2Num++;
    } else if (item.type === 'added') {
      lines1.push({ type: 'empty', content: '', lineNum: null });
      lines2.push({ type: 'added', content: item.content, lineNum: line2Num + 1 });
      line2Num++;
    } else if (item.type === 'removed') {
      lines1.push({ type: 'removed', content: item.content, lineNum: line1Num + 1 });
      lines2.push({ type: 'empty', content: '', lineNum: null });
      line1Num++;
    }
  });
  
  // 处理 diff2 (file2 vs file3)
  var line2NumFor3 = 0;
  var line3Num = 0;
  var diff2Lines2 = [];
  var diff2Lines3 = [];
  
  diff2.forEach(function(item) {
    if (item.type === 'same') {
      diff2Lines2.push({ type: 'same-2', content: item.content, lineNum: line2NumFor3 + 1 });
      diff2Lines3.push({ type: 'same', content: item.content, lineNum: line3Num + 1 });
      line2NumFor3++;
      line3Num++;
    } else if (item.type === 'added') {
      diff2Lines2.push({ type: 'empty', content: '', lineNum: null });
      diff2Lines3.push({ type: 'added-3', content: item.content, lineNum: line3Num + 1 });
      line3Num++;
    } else if (item.type === 'removed') {
      diff2Lines2.push({ type: 'removed-3', content: item.content, lineNum: line2NumFor3 + 1 });
      diff2Lines3.push({ type: 'empty', content: '', lineNum: null });
      line2NumFor3++;
    }
  });
  
  // 对齐三个文件的行数
  var maxLines = Math.max(lines1.length, lines2.length, diff2Lines2.length, diff2Lines3.length);
  
  // 统计
  var sameCount = 0, addedCount = 0, removedCount = 0;
  
  for (var i = 0; i < maxLines; i++) {
    var l1 = lines1[i] || { type: 'empty', content: '', lineNum: null };
    var l2 = lines2[i] || { type: 'empty', content: '', lineNum: null };
    var l2_2 = diff2Lines2[i] || { type: 'empty', content: '', lineNum: null };
    var l3 = diff2Lines3[i] || { type: 'empty', content: '', lineNum: null };
    
    // 合并 file2 的类型(取更具体的)
    var l2Type = l2.type;
    if (l2_2.type !== 'same-2' && l2_2.type !== 'empty') {
      l2Type = l2_2.type;
      l2.lineNum = l2_2.lineNum;
      l2.content = l2_2.content;
    }
    
    // 统计
    if (l1.type === 'added' || l1.type === 'removed' || 
        l2Type === 'added' || l2Type === 'removed' || l2Type === 'removed-3' ||
        l3.type === 'added-3' || l3.type === 'removed-3') {
      if (l1.type === 'added' || l2Type === 'added' || l3.type === 'added-3') addedCount++;
      if (l1.type === 'removed' || l2Type === 'removed' || l2Type === 'removed-3' || l3.type === 'removed-3') removedCount++;
    } else {
      sameCount++;
    }
    
    // 添加到容器
    addLineToContainer(container1, l1.lineNum, l1.content, l1.type);
    addLineToContainer(container2, l2.lineNum, l2.content, l2Type);
    addLineToContainer(container3, l3.lineNum, l3.content, l3.type);
  }
  
  // 保存差异结果
  diffResults = [];
  for (var i = 0; i < maxLines; i++) {
    var l1 = lines1[i];
    var l2 = lines2[i];
    var l2_2 = diff2Lines2[i];
    var l3 = diff2Lines3[i];
    
    if ((l1 && (l1.type === 'added' || l1.type === 'removed')) ||
        (l2 && (l2.type === 'added' || l2.type === 'removed')) ||
        (l2_2 && (l2_2.type === 'removed-3')) ||
        (l3 && (l3.type === 'added-3' || l3.type === 'removed-3'))) {
      diffResults.push({
        line1: l1 ? l1.lineNum : null,
        line2: l2 ? l2.lineNum : null,
        line3: l3 ? l3.lineNum : null
      });
    }
  }
  
  currentDiffIndex = diffResults.length > 0 ? 0 : -1;
  
  // 更新统计栏
  updateDiffStats(sameCount, addedCount, removedCount);
}

function addLineToContainer(container, lineNum, content, type) {
  var lineDiv = document.createElement('div');
  // 鸿蒙 ArkWeb 不支持多类名选择器,使用连字符连接
  lineDiv.className = 'code-line-' + type;
  
  if (lineNum !== null) {
    var lineNumSpan = document.createElement('span');
    lineNumSpan.className = 'line-number';
    lineNumSpan.textContent = lineNum;
    lineDiv.appendChild(lineNumSpan);
  } else {
    var emptyNum = document.createElement('span');
    emptyNum.className = 'line-number';
    emptyNum.textContent = '';
    lineDiv.appendChild(emptyNum);
  }
  
  var lineContent = document.createElement('span');
  lineContent.className = 'line-content';
  lineContent.textContent = content || ' ';
  lineDiv.appendChild(lineContent);
  
  container.appendChild(lineDiv);
}

function handleSyncScroll(e) {
  if (!synchronizedScroll) return;
  
  var source = e.target;
  var scrollTop = source.scrollTop;
  
  if (source.id === 'file1-content') {
    document.getElementById('file2-content').scrollTop = scrollTop;
    if (compareMode === 'three-way') {
      document.getElementById('file3-content').scrollTop = scrollTop;
    }
  } else if (source.id === 'file2-content') {
    document.getElementById('file1-content').scrollTop = scrollTop;
    if (compareMode === 'three-way') {
      document.getElementById('file3-content').scrollTop = scrollTop;
    }
  } else if (source.id === 'file3-content') {
    document.getElementById('file1-content').scrollTop = scrollTop;
    document.getElementById('file2-content').scrollTop = scrollTop;
  }
}

function toggleSyncScroll() {
  synchronizedScroll = !synchronizedScroll;
  var btn = document.getElementById('btn-sync-scroll');
  btn.classList.toggle('btn-active', synchronizedScroll);
}

// 清空所有文件
function clearAll() {
  file1Data = null;
  file2Data = null;
  file3Data = null;
  diffResults = [];
  currentDiffIndex = -1;
  
  // 重置 UI
  document.getElementById('file1-path').textContent = '未选择';
  document.getElementById('file1-lines').textContent = '';
  document.getElementById('file1-size').textContent = '';
  document.getElementById('file1-content').innerHTML = '';
  
  document.getElementById('file2-path').textContent = '未选择';
  document.getElementById('file2-lines').textContent = '';
  document.getElementById('file2-size').textContent = '';
  document.getElementById('file2-content').innerHTML = '';
  
  document.getElementById('file3-path').textContent = '未选择';
  document.getElementById('file3-lines').textContent = '';
  document.getElementById('file3-size').textContent = '';
  document.getElementById('file3-content').innerHTML = '';
  
  // 隐藏文件 3(如果是双向模式)
  if (compareMode === 'two-way') {
    document.getElementById('file3-info').style.display = 'none';
    document.getElementById('file3-panel').style.display = 'none';
  }
  
  // 隐藏统计栏
  document.getElementById('diff-stats').style.display = 'none';
  
  // 禁用按钮
  document.getElementById('btn-compare').disabled = true;
  
  showMessage('已清空所有文件', 'success');
}

// 更新差异统计
function updateDiffStats(same, added, removed) {
  var statsBar = document.getElementById('diff-stats');
  statsBar.style.display = 'flex';
  
  document.getElementById('stat-same').textContent = same;
  document.getElementById('stat-added').textContent = added;
  document.getElementById('stat-removed').textContent = removed;
  document.getElementById('stat-total').textContent = added + removed;
  
  // 更新导航状态
  if (diffResults.length > 0) {
    document.getElementById('btn-prev-diff').disabled = false;
    document.getElementById('btn-next-diff').disabled = false;
    updateDiffNavigation();
  } else {
    document.getElementById('btn-prev-diff').disabled = true;
    document.getElementById('btn-next-diff').disabled = true;
    document.getElementById('stat-current').textContent = '0/0';
  }
}

// 格式化文件大小
function formatSize(bytes) {
  if (bytes === 0) return '0 B';
  var k = 1024;
  var sizes = ['B', 'KB', 'MB', 'GB'];
  var i = Math.floor(Math.log(bytes) / Math.log(k));
  return (bytes / Math.pow(k, i)).toFixed(1) + ' ' + sizes[i];
}

function showMessage(message, type) {
  var statusBar = document.getElementById('status-bar');
  statusBar.textContent = message;
  statusBar.className = 'status-bar status-' + type;
  
  if (type !== 'error') {
    setTimeout(function() {
      statusBar.textContent = '就绪';
      statusBar.className = 'status-bar';
    }, 3000);
  }
}

关键要点

  • 事件绑定守卫(eventsInitialized),防止热重载重复绑定
  • 文件选择:支持多选,读取文件内容、行数、大小
  • 双向对比:调用主进程 LCS 算法,显示绿色新增 + 红色删除
  • 三向对比:正确对齐三个文件,使用不同颜色区分两个差异
  • 同步滚动:三个面板实时同步滚动位置
  • 差异统计:实时更新相同/新增/删除行数
  • 清空重置:一键恢复初始状态
  • 全中文日志输出,便于调试

3.6 第六步:编写现代化样式文件(meld.css)

文件:web_engine/src/main/resources/resfile/resources/app/styles/meld.css

/* Meld 文件对比工具 - 现代化 UI */

* {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
}

body {
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Microsoft YaHei', sans-serif;
  background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
  color: #2d3748;
  overflow: hidden;
  height: 100vh;
}

/* 主容器 */
body::before {
  content: '';
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  bottom: 0;
  background: rgba(255, 255, 255, 0.95);
  backdrop-filter: blur(10px);
  z-index: -1;
}

/* 工具栏 */
.toolbar {
  background: linear-gradient(to right, #ffffff, #f8f9fa);
  border-bottom: 1px solid #e2e8f0;
  padding: 12px 20px;
  display: flex;
  gap: 16px;
  align-items: center;
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
}

.toolbar-group {
  display: flex;
  gap: 8px;
  padding: 0 12px;
  border-right: 1px solid #e2e8f0;
}

.toolbar-group:last-child {
  border-right: none;
}

.btn {
  padding: 8px 16px;
  border: 1px solid #e2e8f0;
  background: #ffffff;
  border-radius: 8px;
  cursor: pointer;
  font-size: 14px;
  font-weight: 500;
  color: #4a5568;
  transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
}

.btn:hover {
  background: #f7fafc;
  border-color: #cbd5e0;
  transform: translateY(-1px);
  box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}

.btn:active {
  transform: translateY(0);
}

.btn-active {
  background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
  color: white;
  border-color: transparent;
  box-shadow: 0 4px 12px rgba(102, 126, 234, 0.3);
}

.btn:disabled {
  opacity: 0.4;
  cursor: not-allowed;
  transform: none;
}

.btn-primary {
  background: linear-gradient(135deg, #48bb78 0%, #38a169 100%);
  color: white;
  border-color: transparent;
  box-shadow: 0 4px 12px rgba(72, 187, 120, 0.3);
}

.btn-primary:hover {
  background: linear-gradient(135deg, #38a169 0%, #2f855a 100%);
  box-shadow: 0 6px 16px rgba(72, 187, 120, 0.4);
}

.btn-primary:disabled {
  background: linear-gradient(135deg, #a0aec0 0%, #718096 100%);
  color: #ffffff;
  box-shadow: 0 2px 6px rgba(160, 174, 192, 0.3);
  opacity: 0.7;
}

.btn-danger {
  background: linear-gradient(135deg, #fc8181 0%, #f56565 100%);
  color: white;
  border-color: transparent;
  box-shadow: 0 4px 12px rgba(245, 101, 101, 0.3);
}

.btn-danger:hover {
  background: linear-gradient(135deg, #f56565 0%, #e53e3e 100%);
  box-shadow: 0 6px 16px rgba(245, 101, 101, 0.4);
}

/* 文件信息栏 */
.file-info-bar {
  background: linear-gradient(to right, #f7fafc, #ffffff);
  border-bottom: 1px solid #e2e8f0;
  padding: 10px 20px;
  display: flex;
  gap: 24px;
  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
}

.file-info {
  display: flex;
  align-items: center;
  gap: 10px;
  font-size: 13px;
  padding: 6px 12px;
  background: #ffffff;
  border-radius: 8px;
  border: 1px solid #e2e8f0;
  box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
}

.file-label {
  font-weight: 600;
  color: #4a5568;
}

.file-path {
  color: #667eea;
  font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
  font-weight: 500;
}

.file-lines {
  color: #718096;
  font-size: 12px;
  padding: 2px 8px;
  background: #edf2f7;
  border-radius: 4px;
}

.file-size {
  color: #718096;
  font-size: 12px;
  padding: 2px 8px;
  background: #edf2f7;
  border-radius: 4px;
}

/* 差异统计栏 */
.diff-stats {
  background: linear-gradient(to right, #ffffff, #f7fafc);
  border-bottom: 1px solid #e2e8f0;
  padding: 12px 20px;
  display: flex;
  gap: 24px;
  align-items: center;
  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
}

.stat-item {
  display: flex;
  gap: 8px;
  font-size: 13px;
  padding: 6px 12px;
  background: #ffffff;
  border-radius: 8px;
  border: 1px solid #e2e8f0;
  box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
}

.stat-label {
  color: #718096;
  font-weight: 500;
}

.stat-value {
  font-weight: 700;
  color: #2d3748;
}

.stat-same .stat-value {
  color: #48bb78;
}

.stat-added .stat-value {
  color: #4299e1;
}

.stat-removed .stat-value {
  color: #f56565;
}

.stat-separator {
  color: #e2e8f0;
  font-weight: 300;
}

/* 对比区域 */
.compare-container {
  display: flex;
  gap: 3px;
  height: calc(100vh - 180px);
  background: #e2e8f0;
  padding: 3px;
}

.compare-panel {
  flex: 1;
  background: #ffffff;
  display: flex;
  flex-direction: column;
  border-radius: 12px;
  overflow: hidden;
  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
  border: 1px solid #e2e8f0;
}

.panel-header {
  background: linear-gradient(to right, #f7fafc, #edf2f7);
  padding: 10px 16px;
  font-weight: 600;
  font-size: 13px;
  color: #4a5568;
  border-bottom: 2px solid #e2e8f0;
  letter-spacing: 0.5px;
}

.file-content {
  flex: 1;
  overflow: auto;
  font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', 'Courier New', monospace;
  font-size: 14px;
  line-height: 1.7;
  background: #ffffff;
}

/* 代码行 */
.code-line {
  display: flex;
  min-height: 24px;
  border-bottom: 1px solid #f7fafc;
  transition: background-color 0.15s;
}

.code-line:hover {
  background: #f7fafc;
}

.line-number {
  width: 55px;
  padding: 0 10px;
  background: #f7fafc;
  color: #a0aec0;
  text-align: right;
  user-select: none;
  border-right: 1px solid #e2e8f0;
  font-size: 12px;
  font-weight: 500;
}

.line-content {
  flex: 1;
  padding: 0 10px;
  white-space: pre;
  overflow: hidden;
}

/* 差异高亮 - 现代化配色(鸿蒙 ArkWeb 不支持多类名选择器,使用连字符) */
.code-line-same {
  background: #ffffff;
}

.code-line-added {
  background: linear-gradient(to right, #c6f6d5, #9ae6b4);
}

.code-line-added .line-number {
  background: #9ae6b4;
  color: #22543d;
  font-weight: 600;
}

.code-line-removed {
  background: linear-gradient(to right, #fed7d7, #feb2b2);
}

.code-line-removed .line-number {
  background: #feb2b2;
  color: #742a2a;
  font-weight: 600;
}

/* 三向对比的颜色(file2 vs file3) */
.code-line-added-3 {
  background: linear-gradient(to right, #feebc8, #fbd38d);
}

.code-line-added-3 .line-number {
  background: #fbd38d;
  color: #7c2d12;
  font-weight: 600;
}

.code-line-removed-3 {
  background: linear-gradient(to right, #e9d8fd, #d6bcfa);
}

.code-line-removed-3 .line-number {
  background: #d6bcfa;
  color: #44337a;
  font-weight: 600;
}

.code-line-empty {
  background: #f7fafc;
}

.code-line-empty .line-number {
  background: #edf2f7;
}

/* 状态栏 */
.status-bar {
  background: linear-gradient(to right, #ffffff, #f7fafc);
  border-top: 1px solid #e2e8f0;
  padding: 10px 20px;
  font-size: 13px;
  color: #4a5568;
  display: flex;
  justify-content: space-between;
  font-weight: 500;
  box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.05);
}

.status-bar.status-success {
  background: linear-gradient(to right, #c6f6d5, #9ae6b4);
  color: #22543d;
}

.status-bar.status-error {
  background: linear-gradient(to right, #fed7d7, #feb2b2);
  color: #742a2a;
}

.status-bar.status-info {
  background: linear-gradient(to right, #bee3f8, #90cdf4);
  color: #2c5282;
}

/* 滚动条 - 现代化(鸿蒙 ArkWeb 不支持 ::-webkit-scrollbar,已移除) */
/* 如需自定义滚动条,可使用 JavaScript 方案 */

/* 动画效果 */
@keyframes fadeIn {
  from {
    opacity: 0;
    transform: translateY(-10px);
  }
  to {
    opacity: 1;
    transform: translateY(0);
  }
}

.diff-stats {
  animation: fadeIn 0.3s ease-out;
}

.compare-panel {
  animation: fadeIn 0.4s ease-out;
}

关键要点

  • 渐变背景设计:紫色主题(#667eea → #764ba2)+ 毛玻璃效果
  • 卡片化布局:每个信息块都是独立卡片(8px 圆角 + 阴影)
  • 按钮渐变:活跃态紫色/主要按钮绿色/危险按钮红色
  • 悬停动画:transform: translateY(-1px) 上浮效果
  • 差异配色:绿色渐变(新增)、红色渐变(删除)、橙色渐变(三向新增)、紫色渐变(三向删除)
  • 鸿蒙 ArkWeb 兼容:不使用 CSS 变量(var(–xxx)),直接写实际颜色值
  • 淡入动画:统计栏 0.3s、对比面板 0.4s
  • 字体优化:JetBrains Mono 代码字体,系统字体栈

四、部署到鸿蒙平台

4.1 项目结构说明

开发****工作流

  1. 直接在 electron-apps/meld/ 中修改代码
  2. 同步到 web_engine/src/main/resources/resfile/resources/app/
  3. 在 DevEco Studio 中构建并运行
  4. 真机测试验证

4.2 构建 HAP 包

在 DevEco Studio 中:

  1. 打开项目根目录 ohos_hap/
  2. 点击 Build > Build Hap(s)/APP(s)
  3. 选择 Build Hap(s)
  4. 等待构建完成

4.3 真机测试

  1. 连接鸿蒙设备(或启动模拟器)
  2. 点击 Run > Run ‘entry’
  3. 安装完成后,应用会自动启动

五、常见问题 FAQ

Q1:三向对比时文件 2 颜色混乱,既有绿色也有紫色?

问题现象:三向对比时,文件 2 同时显示绿色(新增)和紫色(删除),颜色混乱

根本原因:文件 2 参与了两个对比(file1 vs file2 和 file2 vs file3),之前的实现被渲染了两次

真实代码(renderer.js 第 262-340 行):

function displayThreeWayAligned(diff1, diff2, container1, container2, container3) {
  // 创建行映射
  var lines1 = [];
  var lines2 = [];
  var lines3 = [];
  
  // 处理 diff1 (file1 vs file2)
  var line1Num = 0;
  var line2Num = 0;
  
  diff1.forEach(function(item) {
    if (item.type === 'same') {
      lines1.push({ type: 'same', content: item.content, lineNum: line1Num + 1 });
      lines2.push({ type: 'same', content: item.content, lineNum: line2Num + 1 });
      line1Num++;
      line2Num++;
    } else if (item.type === 'added') {
      lines1.push({ type: 'empty', content: '', lineNum: null });
      lines2.push({ type: 'added', content: item.content, lineNum: line2Num + 1 });
      line2Num++;
    } else if (item.type === 'removed') {
      lines1.push({ type: 'removed', content: item.content, lineNum: line1Num + 1 });
      lines2.push({ type: 'empty', content: '', lineNum: null });
      line1Num++;
    }
  });
  
  // 处理 diff2 (file2 vs file3)
  var line2NumFor3 = 0;
  var line3Num = 0;
  var diff2Lines2 = [];
  var diff2Lines3 = [];
  
  diff2.forEach(function(item) {
    if (item.type === 'same') {
      diff2Lines2.push({ type: 'same-2', content: item.content, lineNum: line2NumFor3 + 1 });
      diff2Lines3.push({ type: 'same', content: item.content, lineNum: line3Num + 1 });
      line2NumFor3++;
      line3Num++;
    } else if (item.type === 'added') {
      diff2Lines2.push({ type: 'empty', content: '', lineNum: null });
      diff2Lines3.push({ type: 'added-3', content: item.content, lineNum: line3Num + 1 });
      line3Num++;
    } else if (item.type === 'removed') {
      diff2Lines2.push({ type: 'removed-3', content: item.content, lineNum: line2NumFor3 + 1 });
      diff2Lines3.push({ type: 'empty', content: '', lineNum: null });
      line2NumFor3++;
    }
  });
  
  // 对齐三个文件的行数
  var maxLines = Math.max(lines1.length, lines2.length, diff2Lines2.length, diff2Lines3.length);
  
  for (var i = 0; i < maxLines; i++) {
    var l1 = lines1[i] || { type: 'empty', content: '', lineNum: null };
    var l2 = lines2[i] || { type: 'empty', content: '', lineNum: null };
    var l2_2 = diff2Lines2[i] || { type: 'empty', content: '', lineNum: null };
    var l3 = diff2Lines3[i] || { type: 'empty', content: '', lineNum: null };
    
    // ⭐ 合并 file2 的类型(取更具体的)
    var l2Type = l2.type;
    if (l2_2.type !== 'same-2' && l2_2.type !== 'empty') {
      l2Type = l2_2.type;
      l2.lineNum = l2_2.lineNum;
      l2.content = l2_2.content;
    }
    
    // 添加到容器
    addLineToContainer(container1, l1.lineNum, l1.content, l1.type);
    addLineToContainer(container2, l2.lineNum, l2.content, l2Type);
    addLineToContainer(container3, l3.lineNum, l3.content, l3.type);
  }
}

解决方案关键点:

  • 分别处理两个 diff(diff1 和 diff2),避免文件 2 被渲染两次
  • 创建独立的行映射数组(lines1、lines2、diff2Lines2、diff2Lines3)
  • 合并 file2 的类型时,取更具体的那个(l2_2.type !== ‘same-2’ && l2_2.type !== ‘empty’)
  • 使用不同的 CSS 类名区分两个对比(added vs added-3,removed vs removed-3)

Q2:选择文件失败,报错 “Error invoking remote method”?

问题现象:点击"选择文件"按钮后,控制台报错:Error invoking remote method ‘select-file’

根本原因:setupIpcHandlers() 函数定义了但没有在 app.whenReady() 中被调用

真实代码(main.js 第 35-39 行):

// ✅ 正确代码(main.js 第 35-39 行)
app.whenReady().then(() => {
  createWindow();
  setupIpcHandlers();  // ⭐ 必须调用
  console.log('Meld 文件对比工具已启动');
});

preload.js 中的 API 定义(第 1-10 行):

// Meld preload 脚本
const { contextBridge, ipcRenderer } = require('electron');

contextBridge.exposeInMainWorld('electronAPI', {
  selectFile: () => ipcRenderer.invoke('select-file'),
  readFile: (filePath) => ipcRenderer.invoke('read-file', filePath),
  computeDiff: (file1Lines, file2Lines) => ipcRenderer.invoke('compute-diff', file1Lines, file2Lines),
  computeThreeWayDiff: (file1Lines, file2Lines, file3Lines) => ipcRenderer.invoke('compute-three-way-diff', file1Lines, file2Lines, file3Lines)
});

解决方案关键点:

  • IPC 处理器必须在 app.whenReady() 中显式调用 setupIpcHandlers()
  • preload.js 使用 contextBridge.exposeInMainWorld 直接暴露方法(不是通用 invoke)
  • 启用 contextIsolation: true 提升安全性(main.js 第 21 行)
  • 渲染进程通过 window.electronAPI.selectFile() 直接调用(renderer.js 第 45 行)

Q3:页面白屏,不显示任何内容?

问题现象:应用启动后,页面完全白屏,不显示任何 UI

根本原因:BrowserWindow 加载的路径错误

真实代码(main.js 第 14-26 行):

function createWindow() {
  console.log('Meld: Creating window...');
  
  const mainScreen = screen.getPrimaryDisplay();
  const { width, height } = mainScreen.workAreaSize;
  
  mainWindow = new BrowserWindow({
    width: Math.floor(width * 0.9),
    height: Math.floor(height * 0.9),
    x: Math.floor(width * 0.05),
    y: Math.floor(height * 0.05),
    webPreferences: {
      nodeIntegration: false,
      contextIsolation: true,
      preload: path.join(__dirname, 'preload.js')
    }
  });

  mainWindow.loadFile('index.html');  // ⭐ 使用相对路径

  mainWindow.on('closed', () => {
    mainWindow = null;
  });
  
  console.log('Meld: Window created');
}

解决方案关键点:

  • 使用 mainWindow.loadFile(‘index.html’) 相对路径(不是 path.join)
  • 确保 index.html 文件与 main.js 在同一目录
  • 窗口尺寸使用屏幕 90%(width * 0.9)
  • 检查 preload.js 路径是否正确

Q4:文件 3 没有滚动条,内容超出无法查看?

问题现象:三向对比时,文件 3 的内容超出了容器,但没有出现滚动条

根本原因:CSS 中文件 3 的容器没有设置 overflow-y: auto 属性

真实代码(renderer.js 第 244-260 行):

function displayThreeWayDiff(diff1, diff2) {
  var file1Container = document.getElementById('file1-content');
  var file2Container = document.getElementById('file2-content');
  var file3Container = document.getElementById('file3-content');
  
  file1Container.innerHTML = '';
  file2Container.innerHTML = '';
  file3Container.innerHTML = '';
  
  // 三向对比:正确的方式是三个文件对齐显示
  // file1: 相对 file2 的差异(红色=删除,绿色=相对于file2的新增)
  // file2: 基准文件(白色=相同,其他颜色标记与file1/file3的差异)
  // file3: 相对 file2 的差异(橙色=新增,紫色=删除)
  
  // 简化方案:分别显示,但 file2 只显示一次,使用不同的标记
  displayThreeWayAligned(diff1, diff2, file1Container, file2Container, file3Container);
}

对应的 CSS 样式(meld.css 第 248-255 行):

.file-content {
  flex: 1;  /* ⭐ 平均分配空间 */
  overflow: auto;  /* ⭐ 关键:启用垂直和水平滚动条 */
  font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', 'Courier New', monospace;
  font-size: 14px;
  line-height: 1.7;
  background: #ffffff;
}

解决方案关键点:

  • 确保 .file-content 设置了 overflow: auto(不是 hidden 或 scroll)
  • auto 只在内容超出时才显示滚动条,更美观
  • 同时支持垂直和水平滚动
  • 父容器 .compare-panel 使用 flex: 1 平均分配空间

Q5:鸿蒙平台 CSS 样式不生效,页面显示异常?

问题现象:在鸿蒙设备上运行时,部分 CSS 样式没有生效,页面布局混乱

根本原因:鸿蒙 ArkWeb 不支持 CSS 自定义属性(变量)var(–xxx)

真实代码(meld.css 多处):

/* ❌ 错误:使用 CSS 变量(不兼容) */
:root {
  --primary-color: #667eea;
  --toolbar-bg: #ffffff;
}

.toolbar {
  background: var(--toolbar-bg);  /* ❌ 不生效 */
}

.btn:hover {
  background: var(--hover-color);  /* ❌ 不生效 */
}

/* ✅ 正确:使用实际值(兼容) */
.toolbar {
  background: linear-gradient(to right, #ffffff, #f8f9fa);  /* ⭐ 渐变背景 */
}

.btn:hover {
  background: #f7fafc;  /* ⭐ 悬停高亮 */
}

.line-removed {
  background: #fef2f2;  /* ⭐ 浅红色背景,不是 var(--danger-light) */
}

.line-added {
  background: #f0fdf4;  /* ⭐ 浅绿色背景,不是 var(--success-light) */
}

解决方案关键点:

  • 将所有 var(–xxx) 替换为实际值
  • 鸿蒙 ArkWeb 不支持 CSS 自定义属性
  • 项目已 100% 去除 CSS 变量(meld.css 400+ 行)
  • 其他 CSS 特性(flex、transition、transform、gradient)均支持
  • 使用语义化颜色命名(如 #fef2f2 表示浅红色)

Q6:大文件对比(1000+ 行)响应慢,界面卡顿?

问题现象:对比超过 1000 行的大文件时,界面卡顿,用户体验差

根本原因:LCS(最长公共子序列)算法时间复杂度 O(m×n),主进程执行耗时操作阻塞 UI

真实代码(main.js 第 144-200 行):

// LCS(最长公共子序列)差异算法
function computeDiff(lines1, lines2) {
  const m = lines1.length;
  const n = lines2.length;
  
  // 创建 DP 表
  const dp = Array(m + 1).fill(null).map(() => Array(n + 1).fill(0));
  
  // 填充 DP 表
  for (let i = 1; i <= m; i++) {
    for (let j = 1; j <= n; j++) {
      if (lines1[i - 1] === lines2[j - 1]) {
        dp[i][j] = dp[i - 1][j - 1] + 1;
      } else {
        dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]);
      }
    }
  }
  
  // 回溯找出差异
  const result = [];
  let i = m, j = n;
  
  while (i > 0 || j > 0) {
    if (i > 0 && j > 0 && lines1[i - 1] === lines2[j - 1]) {
      result.unshift({
        type: 'same',
        line1: i - 1,
        line2: j - 1,
        content: lines1[i - 1]
      });
      i--;
      j--;
    } else if (j > 0 && (i === 0 || dp[i][j - 1] >= dp[i - 1][j])) {
      result.unshift({
        type: 'added',
        line1: null,
        line2: j - 1,
        content: lines2[j - 1]
      });
      j--;
    } else if (i > 0) {
      result.unshift({
        type: 'removed',
        line1: i - 1,
        line2: null,
        content: lines1[i - 1]
      });
      i--;
    }
  }
  
  return result;
}

IPC 调用(main.js 第 118-126 行):

// ⭐ 在主进程执行 LCS 算法,避免阻塞 UI 线程
ipcMain.handle('compute-diff', async (event, file1Lines, file2Lines) => {
  try {
    const diff = computeDiff(file1Lines, file2Lines);
    return { diff: diff };
  } catch (error) {
    console.error('Meld: 计算差异失败:', error);
    return { error: error.message };
  }
});

解决方案关键点:

  • LCS 算法必须在主进程执行(不是渲染进程)
  • 使用 async/await 异步调用,避免阻塞 UI
  • 显示“正在对比…”加载状态(renderer.js 第 587 行)
  • 对于超大文件(>10000 行),考虑使用 Web Worker 或分块计算
  • 实际测试:1000 行文件对比耗时约 50-100ms

Q7:“开始对比”按钮在禁用状态下几乎看不见?

问题现象:未选择文件时,“开始对比”按钮颜色太浅,在白色背景上不明显

根本原因:CSS 禁用状态使用了纯灰色 #e2e8f0,对比度不够

真实代码(meld.css 第 89-118 行):

/* 主按钮 */
.btn-primary {
  background: linear-gradient(135deg, #48bb78 0%, #38a169 100%);
  color: white;
  border-color: transparent;
  box-shadow: 0 4px 12px rgba(72, 187, 120, 0.3);
}

.btn-primary:hover {
  background: linear-gradient(135deg, #38a169 0%, #2f855a 100%);
  box-shadow: 0 6px 16px rgba(72, 187, 120, 0.4);
}

.btn-primary:disabled {
  background: linear-gradient(135deg, #a0aec0 0%, #718096 100%);  /* ⭐ 灰色渐变 */
  color: #ffffff;
  box-shadow: 0 2px 6px rgba(160, 174, 192, 0.3);
  opacity: 0.7;  /* ⭐ 透明度表示禁用 */
}

/* 危险按钮 */
.btn-danger {
  background: linear-gradient(135deg, #fc8181 0%, #f56565 100%);
  color: white;
  border-color: transparent;
  box-shadow: 0 4px 12px rgba(245, 101, 101, 0.3);
}

.btn-danger:hover {
  background: linear-gradient(135deg, #f56565 0%, #e53e3e 100%);
  box-shadow: 0 6px 16px rgba(245, 101, 101, 0.4);
}

解决方案关键点:

  • 禁用状态使用灰色渐变 linear-gradient(135deg, #a0aec0 0%, #718096 100%)
  • 保持渐变风格与启用状态一致
  • opacity: 0.7 提供视觉区分
  • color: #ffffff 白色文字确保可读性
  • cursor: not-allowed 鼠标样式提示禁用
Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐