鸿蒙平台 H2 SQL 工作台适配实战:基于 Electron 壳方案的 Navicat 风格数据库管理工具开发

H2 是一款类似 Navicat 的轻量级 SQL 数据库工作台,支持 CREATE/INSERT/SELECT/UPDATE/DELETE 等标准 SQL 语句执行、多标签管理、可编辑数据网格、数据库对象浏览器、查询历史和深色主题等功能。本文记录将其基于 Electron 壳方案适配到鸿蒙 PC 平台的完整流程。

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

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

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

一、技术架构分析

1.1 功能定位

H2 数据库工作台的核心功能是执行 SQL 语句并展示结果。由于 H2 原版基于 Java,无法在鸿蒙平台直接运行,因此采用纯 HTML/CSS/JavaScript 实现了一个功能等价的 SQL 工作台,内置轻量级 SQL 引擎,支持内存数据库的增删改查操作。

1.2 目标架构(鸿蒙 Electron)

  • 技术栈:Electron + HTML/CSS/JavaScript + 鸿蒙 web_engine 模块
  • 核心逻辑:全部在渲染进程运行,使用 JavaScript 实现内存 SQL 引擎,无需后端服务
  • 交互模式:Navicat 风格,左侧对象树 + 右侧多标签工作区,支持可编辑网格

1.3 架构设计

层级 职责 技术实现
主进程 窗口管理 Electron BrowserWindow
渲染进程 SQL 引擎 + UI 交互 原生 DOM + JavaScript
SQL 引擎 语句解析 + 执行 正则匹配 + 内存数据存储
标签系统 多类型标签管理 sql 类型 + table 类型双模式
样式层 深色主题 Catppuccin Mocha 配色方案

1.4 鸿蒙平台适配要点

鸿蒙 Electron 适配层存在三大兼容约束,必须在开发中严格遵守:

  • 禁止使用原生 prompt/confirm/alert 对话框,调用会导致 SubWindow 崩溃
  • 禁止使用原生 select 元素,调用会触发 SubWindow 崩溃
  • 禁止使用 setWindowOpenHandler 和 will-navigate API,调用会导致页面纯白

二、环境准备

2.1 开发环境要求

  • 操作系统:Windows 10/11
  • 开发工具:DevEco Studio(鸿蒙官方 IDE)
  • HarmonyOS SDK:API 21+(5.0.5+)
  • Node.js:v20+

2.2 项目结构

bash 复制代码
ohos_hap/
├── electron-apps/
│   └── H2/                     # H2 SQL 工作台应用源码
│       ├── main.js              # Electron 主进程(窗口管理)
│       ├── renderer.js          # 渲染进程(SQL 引擎 + 核心逻辑)
│       ├── index.html           # HTML 布局(三栏结构)
│       ├── package.json         # 项目配置
│       └── styles/
│           └── h2.css           # Catppuccin Mocha 深色主题
├── web_engine/                  # 鸿蒙 web_engine 模块
│   └── src/main/resources/
│       └── resfile/resources/app/  # 部署目录
└── build-profile.json5          # 鸿蒙构建配置

三、核心适配流程

3.1 第一步:创建主进程

文件:electron-apps/H2/main.js

主进程负责创建浏览器窗口,配置 nodeIntegration 和 contextIsolation 以支持渲染进程使用 Node.js 能力:

js 复制代码
// H2 SQL 工作台 主进程
const { app, BrowserWindow, screen } = require('electron');

let mainWindow = null;

function createWindow() {
  const display = screen.getPrimaryDisplay();
  const { width, height } = display.workAreaSize;

  mainWindow = new BrowserWindow({
    width: Math.floor(width * 0.9),
    height: Math.floor(height * 0.85),
    webPreferences: {
      nodeIntegration: true,
      contextIsolation: false
    }
  });

  mainWindow.loadFile('index.html');
}

app.whenReady().then(createWindow);

app.on('window-all-closed', () => {
  app.quit();
});

3.2 第二步:设计三栏布局

文件:electron-apps/H2/index.html

页面采用三栏布局:左侧连接管理 + 对象浏览器、中间 SQL 编辑器 + 结果面板、右侧可编辑表格视图。工具栏提供新建查询、执行、清空、历史四个操作按钮:

js 复制代码
<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <title>H2 - SQL 工作台</title>
  <link rel="stylesheet" href="styles/h2.css">
</head>
<body>
  <!-- 顶部工具栏 -->
  <div class="toolbar">
    <div class="toolbar-brand">H2</div>
    <div class="toolbar-actions">
      <button class="tool-btn" id="btnNewQuery" title="新建查询 (Ctrl+T)">📝 新建</button>
      <button class="tool-btn primary" id="btnExecute" title="执行 (Ctrl+Enter)">▶ 执行</button>
      <button class="tool-btn" id="btnClear" title="清空编辑器">🗑 清空</button>
      <button class="tool-btn" id="btnHistory" title="查询历史">📋 历史</button>
    </div>
    <div class="toolbar-info">
      <span class="conn-status" id="connStatus">● 内存数据库</span>
    </div>
  </div>

  <!-- 主内容区 -->
  <div class="main">
    <!-- 左侧面板:连接 + 对象浏览器 -->
    <div class="sidebar" id="sidebar">
      <!-- 连接管理 -->
      <div class="sidebar-section">
        <div class="section-header">
          <span>连接</span>
          <button class="btn-icon" id="btnNewConn" title="新建连接">+</button>
        </div>
        <div class="conn-list" id="connList">
          <div class="conn-item active" data-conn="mem">
            <span class="conn-icon">🗄</span>
            <span class="conn-name">内存数据库</span>
          </div>
        </div>
      </div>

      <!-- 数据库对象浏览器 -->
      <div class="sidebar-section flex-1">
        <div class="section-header">
          <span>数据库对象</span>
          <button class="btn-icon" id="btnRefresh" title="刷新">🔄</button>
        </div>
        <div class="db-tree" id="dbTree">
          <div class="tree-empty">请先连接数据库</div>
        </div>
      </div>
    </div>

    <!-- 左侧拖动条 -->
    <div class="sidebar-resizer" id="sidebarResizer"></div>

    <!-- 右侧工作区 -->
    <div class="workspace">
      <!-- 查询标签栏 -->
      <div class="query-tabs" id="queryTabs">
        <div class="query-tab active" data-query-id="1">
          <span class="query-tab-name">查询 1</span>
          <span class="query-tab-close" data-query-id="1">✕</span>
        </div>
        <div class="query-tab-add" id="btnAddQuery" title="新建查询">+</div>
      </div>

      <!-- SQL 编辑器 -->
      <div class="editor-container">
        <textarea class="sql-editor" id="sqlEditor" placeholder="输入 SQL 语句..." spellcheck="false"></textarea>
      </div>

      <!-- 水平拖动条(编辑器/结果) -->
      <div class="horizontal-resizer" id="horizontalResizer"></div>

      <!-- 结果面板 -->
      <div class="results-panel" id="resultsPanel">
        <div class="results-header">
          <div class="results-tabs">
            <div class="results-tab active" data-rpanel="table">结果</div>
            <div class="results-tab" data-rpanel="messages">消息</div>
          </div>
          <div class="results-info" id="resultsInfo"></div>
        </div>
        <div class="results-content">
          <div class="results-table-wrap" id="resultsTableWrap">
            <div class="results-empty" id="resultsEmpty">点击「执行」运行 SQL 查询</div>
            <table class="results-table" id="resultsTable" style="display:none">
              <thead id="resultsThead"></thead>
              <tbody id="resultsTbody"></tbody>
            </table>
          </div>
          <div class="messages-panel" id="messagesPanel" style="display:none">
            <div class="message-list" id="messageList"></div>
          </div>
        </div>
      </div>

      <!-- 表格视图(结构/数据编辑) -->
      <div class="table-view" id="tableView" style="display:none">
        <div class="table-view-header" id="tableViewToolbar">
          <span class="table-view-title" id="tableViewTitle"></span>
        </div>
        <div class="table-view-grid" id="tableViewGrid"></div>
      </div>
    </div>
  </div>

  <!-- 底部状态栏 -->
  <div class="statusbar">
    <span id="statusLeft">就绪</span>
    <span id="statusRight">Ctrl+Enter 执行 | Ctrl+T 新建查询</span>
  </div>

  <!-- 新建连接对话框 -->
  <div class="modal-overlay" id="modalNewConn" style="display:none">
    <div class="modal">
      <div class="modal-header">
        <span>新建连接</span>
        <button class="btn-icon modal-close" data-modal="modalNewConn">✕</button>
      </div>
      <div class="modal-body">
        <div class="form-group">
          <label>连接名称</label>
          <input type="text" id="connName" placeholder="例如:本地数据库" />
        </div>
        <div class="form-group">
          <label>数据库类型</label>
          <div class="custom-select" id="dbTypeSelect">
            <div class="custom-select-trigger" id="dbTypeTrigger">
              <span id="dbTypeLabel">H2 (内存)</span>
              <span class="dropdown-arrow">▼</span>
            </div>
            <div class="custom-select-options" id="dbTypeOptions" style="display:none">
              <div class="custom-select-option active" data-value="h2-mem">H2 (内存)</div>
              <div class="custom-select-option" data-value="h2-file">H2 (文件)</div>
              <div class="custom-select-option" data-value="sqlite">SQLite</div>
              <div class="custom-select-option" data-value="mysql">MySQL</div>
              <div class="custom-select-option" data-value="postgresql">PostgreSQL</div>
            </div>
          </div>
        </div>
        <div class="form-group">
          <label>JDBC URL</label>
          <input type="text" id="connUrl" placeholder="jdbc:h2:mem:testdb" />
        </div>
        <div class="form-group">
          <label>用户名</label>
          <input type="text" id="connUser" placeholder="sa" />
        </div>
        <div class="form-group">
          <label>密码</label>
          <input type="password" id="connPass" placeholder="" />
        </div>
      </div>
      <div class="modal-footer">
        <button class="btn-secondary" data-modal="modalNewConn">取消</button>
        <button class="btn-primary" id="btnSaveConn">连接</button>
      </div>
    </div>
  </div>

  <!-- 历史面板 -->
  <div class="history-overlay" id="historyOverlay" style="display:none">
    <div class="history-panel">
      <div class="history-header">
        <span>查询历史</span>
        <button class="btn-icon" id="btnClearHistory">🗑</button>
        <button class="btn-icon modal-close" data-modal="historyOverlay">✕</button>
      </div>
      <div class="history-list" id="historyList">
        <div class="history-empty">暂无历史记录</div>
      </div>
    </div>
  </div>

  <!-- 应用逻辑 -->
  <script src="renderer.js"></script>
</body>
</html>

数据库类型选择使用自定义 div 下拉框替代原生 select 元素,避免触发鸿蒙 SubWindow 崩溃。新建连接对话框和关闭按钮同样使用自定义 div 实现。

3.3 第三步:实现内存 SQL 引擎

文件:electron-apps/H2/renderer.js

renderer.js 是整个应用的核心,包含状态管理、SQL 引擎、标签系统、对象浏览器等全部逻辑。首先定义全局状态变量:

js 复制代码
// H2 SQL 工作台 渲染进程
// ============================================
// 状态管理
// ============================================
let tables = {};
let queryTabs = {};
let activeTabId = 1;
let nextTabId = 2;
let connections = [{ id: 'mem', name: '内存数据库', type: 'h2-mem', url: 'jdbc:h2:mem:testdb' }];
let activeConnId = 'mem';
let queryHistory = [];
let messages = [];
let isDraggingSidebar = false;
let isDraggingHorizontal = false;

SQL 引擎入口函数负责将多条 SQL 语句拆分后逐条执行:

js 复制代码
// ============================================
// 简易 SQL 引擎
// ============================================
function executeSQL(sql) {
  const statements = splitStatements(sql);
  const results = [];
  for (const stmt of statements) {
    const trimmed = stmt.trim();
    if (!trimmed) continue;
    try {
      results.push(executeStatement(trimmed));
    } catch (err) {
      results.push({ type: 'error', message: err.message });
    }
  }
  return results;
}

语句分发函数根据 SQL 关键字路由到对应的执行函数:

js 复制代码
function executeStatement(sql) {
  const upper = sql.toUpperCase().replace(/\s+/g, ' ').trim();

  if (upper.startsWith('CREATE TABLE')) return execCreateTable(sql);
  if (upper.startsWith('INSERT INTO')) return execInsert(sql);
  if (upper.startsWith('SELECT')) return execSelect(sql);
  if (upper.startsWith('UPDATE')) return execUpdate(sql);
  if (upper.startsWith('DELETE')) return execDelete(sql);
  if (upper.startsWith('DROP TABLE')) return execDropTable(sql);
  if (upper.startsWith('SHOW TABLES')) return execShowTables();
  if (upper.startsWith('DESCRIBE') || upper.startsWith('DESC ')) return execDescribe(sql);

  throw new Error('不支持的 SQL 语句: ' + sql.substring(0, 50));
}

CREATE TABLE 通过正则提取表名和列定义,解析主键和非空约束:

js 复制代码
function execCreateTable(sql) {
  const match = sql.match(/CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?(\w+)\s*\(([^)]+)\)/i);
  if (!match) throw new Error('CREATE TABLE 语法错误');
  const tableName = match[1].toLowerCase();
  const colDefs = match[2].split(',').map(c => c.trim());
  const columns = [];
  for (const def of colDefs) {
    const parts = def.split(/\s+/);
    const colName = parts[0];
    const colType = parts[1] || 'VARCHAR';
    const isPK = /PRIMARY\s+KEY/i.test(def);
    const isNotNull = /NOT\s+NULL/i.test(def);
    columns.push({ name: colName, type: colType.toUpperCase(), primaryKey: isPK, notNull: isNotNull });
  }
  if (tables[tableName]) throw new Error('表 ' + tableName + ' 已存在');
  tables[tableName] = { columns: columns, rows: [] };
  return { type: 'message', text: '表 ' + tableName + ' 创建成功', affected: 0 };
}

INSERT INTO 解析 VALUES 子句中的值并映射到对应列:

js 复制代码
function execInsert(sql) {
  const match = sql.match(/INSERT\s+INTO\s+(\w+)\s*(?:\(([^)]+)\))?\s*VALUES\s*\(([^)]+)\)/i);
  if (!match) throw new Error('INSERT 语法错误');
  const tableName = match[1].toLowerCase();
  if (!tables[tableName]) throw new Error('表 ' + tableName + ' 不存在');
  const table = tables[tableName];
  const values = parseValues(match[3]);
  const row = {};
  table.columns.forEach((col, i) => {
    row[col.name] = values[i] !== undefined ? values[i] : null;
  });
  table.rows.push(row);
  return { type: 'message', text: '插入 1 行', affected: 1 };
}

SELECT 支持 WHERE 条件过滤、ORDER BY 排序和 LIMIT 限制:

js 复制代码
function execSelect(sql) {
  const match = sql.match(/SELECT\s+(.+?)\s+FROM\s+(\w+)(?:\s+WHERE\s+(.+?))?(?:\s+ORDER\s+BY\s+(.+?))?(?:\s+LIMIT\s+(\d+))?$/i);
  if (!match) {
    if (/SELECT\s+(.+?)\s+FROM\s+(\w+)/i.test(sql)) {
      const simpleMatch = sql.match(/SELECT\s+(.+?)\s+FROM\s+(\w+)/i);
      return doSelect(simpleMatch[1], simpleMatch[2].toLowerCase(), null, null, null);
    }
    throw new Error('SELECT 语法错误');
  }
  return doSelect(match[1], match[2].toLowerCase(), match[3] || null, match[4] || null, match[5] ? parseInt(match[5]) : null);
}

WHERE 条件评估支持等值、不等、大小比较和 LIKE 模糊匹配,多个条件以 AND 连接:

js 复制代码
function evaluateWhere(row, expr) {
  const andParts = expr.split(/\s+AND\s+/i);
  return andParts.every(part => {
    const match = part.match(/(\w+)\s*(=|!=|<>|>|<|>=|<=|LIKE)\s*(.+)/i);
    if (!match) return true;
    const col = match[1];
    const op = match[2].toUpperCase();
    let val = match[3].trim();
    if ((val.startsWith("'") && val.endsWith("'")) || (val.startsWith('"') && val.endsWith('"'))) {
      val = val.slice(1, -1);
    }
    const rowVal = String(row[col] || '');
    switch (op) {
      case '=': return rowVal === val;
      case '!=': case '<>': return rowVal !== val;
      case '>': return parseFloat(rowVal) > parseFloat(val);
      case '<': return parseFloat(rowVal) < parseFloat(val);
      case '>=': return parseFloat(rowVal) >= parseFloat(val);
      case '<=': return parseFloat(rowVal) <= parseFloat(val);
      case 'LIKE': return new RegExp('^' + val.replace(/%/g, '.*').replace(/_/g, '.') + '$', 'i').test(rowVal);
      default: return true;
    }
  });
}

UPDATE 解析 SET 子句并逐行匹配 WHERE 条件后更新:

js 复制代码
function execUpdate(sql) {
  const match = sql.match(/UPDATE\s+(\w+)\s+SET\s+(.+?)(?:\s+WHERE\s+(.+))?$/i);
  if (!match) throw new Error('UPDATE 语法错误');
  const tableName = match[1].toLowerCase();
  if (!tables[tableName]) throw new Error('表 ' + tableName + ' 不存在');
  const table = tables[tableName];
  const setClauses = parseSetClauses(match[2]);
  let affected = 0;
  table.rows.forEach(row => {
    if (!match[3] || evaluateWhere(row, match[3])) {
      setClauses.forEach(clause => { row[clause.col] = clause.val; });
      affected++;
    }
  });
  return { type: 'message', text: '更新 ' + affected + ' 行', affected: affected };
}

DELETE 根据 WHERE 条件过滤后删除匹配行,不带条件则清空全表:

js 复制代码
function execDelete(sql) {
  const match = sql.match(/DELETE\s+FROM\s+(\w+)(?:\s+WHERE\s+(.+))?$/i);
  if (!match) throw new Error('DELETE 语法错误');
  const tableName = match[1].toLowerCase();
  if (!tables[tableName]) throw new Error('表 ' + tableName + ' 不存在');
  const table = tables[tableName];
  const before = table.rows.length;
  if (match[2]) {
    table.rows = table.rows.filter(row => !evaluateWhere(row, match[2]));
  } else {
    table.rows = [];
  }
  const affected = before - table.rows.length;
  return { type: 'message', text: '删除 ' + affected + ' 行', affected: affected };
}

SHOW TABLES 和 DESCRIBE 返回元数据查询结果:

js 复制代码
function execShowTables() {
  const names = Object.keys(tables);
  return { type: 'result', columns: ['TABLE_NAME'], rows: names.map(n => ({ TABLE_NAME: n })), total: names.length };
}

function execDescribe(sql) {
  const match = sql.match(/(?:DESCRIBE|DESC)\s+(\w+)/i);
  if (!match) throw new Error('DESCRIBE 语法错误');
  const tableName = match[1].toLowerCase();
  if (!tables[tableName]) throw new Error('表 ' + tableName + ' 不存在');
  const table = tables[tableName];
  const rows = table.columns.map(c => ({
    COLUMN_NAME: c.name,
    TYPE: c.type,
    NULL: c.notNull ? 'NO' : 'YES',
    KEY: c.primaryKey ? 'PRI' : ''
  }));
  return { type: 'result', columns: ['COLUMN_NAME', 'TYPE', 'NULL', 'KEY'], rows: rows, total: rows.length };
}

3.4 第四步:SQL 注释解析

SQL 语句中经常使用 -- 单行注释,splitStatements 函数在拆分语句时需要识别并跳过注释行,否则注释文本会被当作 SQL 语句解析导致报错:

js 复制代码
function splitStatements(sql) {
  const stmts = [];
  let current = '';
  let inString = false;
  let stringChar = '';
  let i = 0;
  while (i < sql.length) {
    const ch = sql[i];
    if (inString) {
      current += ch;
      if (ch === stringChar) inString = false;
      i++;
    } else if (ch === "'" || ch === '"') {
      inString = true;
      stringChar = ch;
      current += ch;
      i++;
    } else if (ch === '-' && sql[i + 1] === '-') {
      // 跳过单行注释 --
      while (i < sql.length && sql[i] !== '\n') i++;
    } else if (ch === ';') {
      stmts.push(current.trim());
      current = '';
      i++;
    } else {
      current += ch;
      i++;
    }
  }
  if (current.trim()) stmts.push(current.trim());
  return stmts;
}

该函数同时处理字符串字面量(单引号和双引号),确保字符串内的分号不会被误判为语句分隔符。

3.5 第五步:Navicat 风格标签系统

标签系统支持两种类型:sql 类型用于 SQL 查询编辑,table 类型用于表格结构和数据的查看与编辑。点击数据库对象树中的结构或数据按钮会新开一个标签页:

js 复制代码
// ============================================
// 查询标签管理
// ============================================
function createQueryTab(sql) {
  const id = nextTabId++;
  queryTabs[id] = { type: 'sql', sql: sql || '', name: '查询 ' + id };
  activeTabId = id;
  renderQueryTabs();
  loadTabContent();
  return id;
}

function createTableViewTab(tableName, viewMode) {
  // 检查是否已存在同表同模式的标签
  for (const id in queryTabs) {
    const tab = queryTabs[id];
    if (tab.type === 'table' && tab.tableName === tableName && tab.viewMode === viewMode) {
      activeTabId = Number(id);
      renderQueryTabs();
      loadTabContent();
      return Number(id);
    }
  }
  const id = nextTabId++;
  const icon = viewMode === 'structure' ? '📐' : '📊';
  queryTabs[id] = { type: 'table', tableName: tableName, viewMode: viewMode, name: icon + ' ' + tableName };
  activeTabId = id;
  renderQueryTabs();
  loadTabContent();
  return id;
}

loadTabContent 根据当前标签类型切换 UI 显示模式。SQL 标签显示编辑器和结果面板,表格标签隐藏编辑器并显示可编辑网格:

js 复制代码
function loadTabContent() {
  const tab = queryTabs[activeTabId];
  const editorContainer = document.querySelector('.editor-container');
  const horizontalResizer = document.getElementById('horizontalResizer');
  const resultsPanel = document.getElementById('resultsPanel');
  const tableView = document.getElementById('tableView');

  if (!tab) return;

  if (tab.type === 'sql') {
    // SQL 查询模式
    editorContainer.style.display = 'flex';
    horizontalResizer.style.display = 'block';
    resultsPanel.style.display = 'flex';
    tableView.style.display = 'none';
    document.getElementById('sqlEditor').value = tab.sql || '';
    // 恢复工具栏按钮状态
    document.getElementById('btnExecute').style.display = '';
    document.getElementById('btnClear').style.display = '';
  } else if (tab.type === 'table') {
    // 表格视图模式
    editorContainer.style.display = 'none';
    horizontalResizer.style.display = 'none';
    resultsPanel.style.display = 'none';
    tableView.style.display = 'flex';
    // 隐藏 SQL 相关按钮
    document.getElementById('btnExecute').style.display = 'none';
    document.getElementById('btnClear').style.display = 'none';
    // 渲染表格视图
    renderTableView(tab.tableName, tab.viewMode);
  }
}

3.6 第六步:可编辑数据网格

数据网格是 Navicat 风格的核心交互。点击单元格时动态创建 input 输入框,Enter 保存编辑,Escape 取消:

js 复制代码
function bindGridEditing(gridWrap) {
  gridWrap.querySelectorAll('.grid-cell').forEach(cell => {
    cell.addEventListener('click', function () {
      if (this.querySelector('input')) return; // 已在编辑
      const currentVal = this.textContent.trim();
      if (currentVal === '点击输入') this.textContent = '';
      const input = document.createElement('input');
      input.type = 'text';
      input.className = 'grid-input';
      input.value = currentVal === '点击输入' ? '' : currentVal;
      this.textContent = '';
      this.appendChild(input);
      input.focus();
      input.select();

      const saveEdit = () => {
        const newVal = input.value;
        const rowIdx = this.dataset.row;
        const colName = this.dataset.col;
        if (rowIdx === 'new') {
          // 新增行
          handleNewRowFromGrid(this, colName, newVal);
        } else {
          // 更新现有单元格
          handleCellUpdate(this, parseInt(rowIdx), colName, newVal);
        }
      };

      input.addEventListener('blur', saveEdit);
      input.addEventListener('keydown', function (e) {
        if (e.key === 'Enter') { e.preventDefault(); input.blur(); }
        if (e.key === 'Escape') { input.value = currentVal; input.blur(); }
        e.stopPropagation();
      });
    });
  });
}

数据网格底部预留一行空行用于新增数据,用户点击空行任意单元格即可输入新记录:

js 复制代码
function renderDataGrid(tableName, table, gridWrap) {
  const cols = table.columns.map(c => c.name);
  let html = '<table class="grid-table"><thead><tr>';
  html += '<th class="row-num">#</th>';
  cols.forEach(c => { html += '<th>' + escapeHtml(c) + '</th>'; });
  html += '</tr></thead><tbody>';
  table.rows.forEach((row, idx) => {
    html += '<tr data-row="' + idx + '">';
    html += '<td class="row-num">' + (idx + 1) + '</td>';
    cols.forEach(c => {
      const v = row[c];
      const display = v !== null && v !== undefined ? String(v) : '';
      html += '<td class="grid-cell" data-col="' + escapeHtml(c) + '" data-row="' + idx + '">' + escapeHtml(display) + '</td>';
    });
    html += '</tr>';
  });
  // 空行用于新增
  html += '<tr class="new-row" data-row="new">';
  html += '<td class="row-num">+</td>';
  cols.forEach(c => {
    html += '<td class="grid-cell grid-new" data-col="' + escapeHtml(c) + '" data-row="new">点击输入</td>';
  });
  html += '</tr>';
  html += '</tbody></table>';
  gridWrap.innerHTML = html;
  bindGridEditing(gridWrap);
}

3.7 第七步:数据库对象浏览器

对象树展示当前数据库中的所有表,支持展开折叠查看表的结构和数据入口。点击结构或数据节点会创建对应的表格视图标签:

js 复制代码
// ============================================
// 数据库对象浏览器
// ============================================
function refreshDbTree() {
  const tree = document.getElementById('dbTree');
  const tableNames = Object.keys(tables);
  if (tableNames.length === 0) {
    tree.innerHTML = '<div class="tree-empty">暂无表</div>';
    return;
  }
  let html = '<div class="tree-node">';
  html += '<div class="tree-label tree-toggle" data-target="tablesGroup">📁 表 (' + tableNames.length + ')</div>';
  html += '<div class="tree-children" id="tablesGroup">';
  tableNames.forEach(name => {
    const table = tables[name];
    html += '<div class="tree-node">';
    html += '<div class="tree-label tree-toggle" data-target="tbl_' + name + '">📋 ' + escapeHtml(name) + ' <span class="row-count">(' + table.rows.length + ')</span></div>';
    html += '<div class="tree-children" id="tbl_' + name + '" style="display:none">';
    // 结构(点击在右侧显示)
    html += '<div class="tree-label tree-action" data-action="structure" data-table="' + escapeHtml(name) + '">📐 结构</div>';
    // 数据(点击在右侧显示)
    html += '<div class="tree-label tree-action" data-action="data" data-table="' + escapeHtml(name) + '">📊 数据</div>';
    html += '</div></div>';
  });
  html += '</div></div>';
  tree.innerHTML = html;

  // 展开/折叠
  tree.querySelectorAll('.tree-toggle').forEach(el => {
    el.addEventListener('click', function (e) {
      e.stopPropagation();
      const target = document.getElementById(this.dataset.target);
      if (target) {
        target.style.display = target.style.display === 'none' ? 'block' : 'none';
        this.classList.toggle('collapsed');
      }
    });
  });

  // 点击结构/数据 → 新建标签页显示
  tree.querySelectorAll('.tree-action').forEach(el => {
    el.addEventListener('click', function (e) {
      e.stopPropagation();
      const action = this.dataset.action;
      const tableName = this.dataset.table;
      if (action === 'structure') {
        createTableViewTab(tableName, 'structure');
      } else if (action === 'data') {
        createTableViewTab(tableName, 'data');
      }
    });
  });
}

3.8 第八步:查询历史与快捷键

每次执行 SQL 后自动记录到历史列表,最多保留 50 条。点击历史记录可将 SQL 回填到编辑器:

js 复制代码
// ============================================
// 查询历史
// ============================================
function addHistory(sql, results) {
  const hasError = results.some(r => r.type === 'error');
  queryHistory.unshift({
    sql: sql,
    timestamp: new Date().toISOString(),
    success: !hasError,
    resultCount: results.length
  });
  if (queryHistory.length > 50) queryHistory.pop();
}

function renderHistory() {
  const list = document.getElementById('historyList');
  if (queryHistory.length === 0) {
    list.innerHTML = '<div class="history-empty">暂无历史记录</div>';
    return;
  }
  list.innerHTML = queryHistory.map((item, idx) => {
    const time = new Date(item.timestamp).toLocaleTimeString();
    const status = item.success ? '✅' : '❌';
    const preview = item.sql.length > 80 ? item.sql.substring(0, 80) + '...' : item.sql;
    return '<div class="history-item" data-idx="' + idx + '">' +
      '<div class="history-meta">' + status + ' ' + time + '</div>' +
      '<div class="history-sql">' + escapeHtml(preview) + '</div>' +
      '</div>';
  }).join('');

  list.querySelectorAll('.history-item').forEach(el => {
    el.addEventListener('click', function () {
      const idx = parseInt(this.dataset.idx);
      document.getElementById('sqlEditor').value = queryHistory[idx].sql;
      syncTabState();
      document.getElementById('historyOverlay').style.display = 'none';
    });
  });
}

快捷键绑定支持 Ctrl+Enter 执行和 Ctrl+T 新建查询:

js 复制代码
// 快捷键
  document.addEventListener('keydown', function (e) {
    if (e.ctrlKey && e.key === 'Enter') {
      e.preventDefault();
      handleExecute();
    } else if (e.ctrlKey && e.key === 't') {
      e.preventDefault();
      createQueryTab('');
    }
  });

3.9 第九步:Catppuccin Mocha 深色主题

文件:electron-apps/H2/styles/h2.css

主题使用 Catppuccin Mocha 配色方案,通过 CSS 变量统一管理所有颜色,确保风格一致性:

js 复制代码
/* H2 SQL 工作台 - Catppuccin Mocha 深色主题 */
:root {
  --base: #1e1e2e;
  --mantle: #181825;
  --crust: #11111b;
  --surface0: #313244;
  --surface1: #45475a;
  --surface2: #585b70;
  --overlay0: #6c7086;
  --overlay1: #7f849c;
  --text: #cdd6f4;
  --text-muted: #a6adc8;
  --blue: #89b4fa;
  --green: #a6e3a1;
  --red: #f38ba8;
  --yellow: #f9e2af;
  --mauve: #cba6f7;
  --teal: #94e2d5;
  --peach: #fab387;
  --border: #313244;
  --border-light: #45475a;
}

可编辑网格的输入框样式,蓝色边框高亮当前编辑单元格:

js 复制代码
.grid-input {
  width: 100%;
  padding: 2px 4px;
  border: 1px solid var(--blue);
  background: var(--surface0);
  color: var(--text);
  font-size: 12px;
  outline: none;
  border-radius: 2px;
}

表格类型标签使用青色底边区分于 SQL 查询标签:

js 复制代码
.query-tab.tab-table {
  border-bottom-color: var(--teal);
}

3.10 第十步:默认测试语句与初始化

应用启动时预置 9 条测试 SQL 语句,覆盖建表、插入、查询、条件过滤、排序、更新、删除、查看表列表和查看表结构等常用操作:

js 复制代码
// ============================================
// 初始化
// ============================================
function init() {
  const defaultSQL = [
    '-- 欢迎使用 H2 SQL 工作台',
    '-- 以下测试语句可逐条选中后点击「执行」或按 Ctrl+Enter 运行',
    '',
    '-- 1. 创建用户表',
    'CREATE TABLE users (id INT PRIMARY KEY, name VARCHAR(100), email VARCHAR(200), age INT);',
    '',
    '-- 2. 插入测试数据',
    "INSERT INTO users VALUES (1, '张三', 'zhangsan@example.com', 28);",
    "INSERT INTO users VALUES (2, '李四', 'lisi@example.com', 32);",
    "INSERT INTO users VALUES (3, '王五', 'wangwu@example.com', 25);",
    "INSERT INTO users VALUES (4, '赵六', 'zhaoliu@example.com', 30);",
    "INSERT INTO users VALUES (5, '孙七', 'sunqi@example.com', 22);",
    '',
    '-- 3. 查询全部数据',
    'SELECT * FROM users;',
    '',
    '-- 4. 条件查询',
    'SELECT * FROM users WHERE age > 28;',
    '',
    '-- 5. 排序与限制',
    'SELECT * FROM users ORDER BY age DESC LIMIT 3;',
    '',
    '-- 6. 更新数据',
    "UPDATE users SET email = 'zhangsan_new@example.com' WHERE id = 1;",
    '',
    '-- 7. 删除数据',
    'DELETE FROM users WHERE id = 5;',
    '',
    '-- 8. 查看所有表',
    'SHOW TABLES;',
    '',
    '-- 9. 查看表结构',
    'DESCRIBE users;'
  ].join('\n');

  queryTabs[1] = { type: 'sql', sql: defaultSQL, name: '查询 1' };
  renderQueryTabs();
  loadTabContent();
  bindEvents();
  initSidebarDrag();
  initHorizontalDrag();
  initCustomSelect();
  renderConnections();
  refreshDbTree();
  document.getElementById('statusLeft').textContent = '就绪 - H2 SQL 工作台';
}

init();

注意初始化标签时必须指定 type: 'sql' 字段,否则 loadTabContent 无法匹配到 SQL 查询模式,编辑器将不会显示。

四、部署到鸿蒙平台

4.1 同步到 web_engine 部署目录

开发完成后,将 electron-apps/H2 目录同步到鸿蒙 web_engine 模块的部署目录:

js 复制代码
# 清空部署目录
Remove-Item -Recurse -Force "web_engine\src\main\resources\resfile\resources\app\*"

# 复制应用文件
Copy-Item -Recurse "electron-apps\H2\*" "web_engine\src\main\resources\resfile\resources\app\"

4.2 构建鸿蒙 HAP

在 DevEco Studio 中打开项目,选择 web_engine 模块,点击 Build > Build Hap(s)/APP(s) > Build Hap(s),生成 HAP 安装包后部署到鸿蒙设备。

4.3 本地调试

在 PowerShell 中可以独立启动 Electron 应用进行调试:

js 复制代码
cd electron-apps\H2
npx electron .

五、常见问题 FAQ

5.1 点击执行按钮没有反应

原因:SQL 解析器 splitStatements 函数没有处理 -- 单行注释,注释文本被当作 SQL 语句解析后报错。

解决:在 splitStatements 中增加 -- 注释识别逻辑,遇到 -- 时跳过到行尾:

js 复制代码
} else if (ch === '-' && sql[i + 1] === '-') {
  // 跳过单行注释 --
  while (i < sql.length && sql[i] !== '\n') i++;
}

5.2 默认查询语句不显示

原因:初始化标签对象缺少 type 字段,loadTabContent 无法判断标签类型。

解决:确保初始化时包含 type: 'sql':

js 复制代码
queryTabs[1] = { type: 'sql', sql: defaultSQL, name: '查询 1' };

5.3 点击数据库对象看不到表数据

原因:对象树默认只显示列定义信息,不显示行数据。

解决:点击对象树中的「数据」节点会新开一个数据视图标签页,以 Navicat 风格的可编辑网格展示表数据,支持单元格编辑和新增行。

5.4 鸿蒙平台对话框崩溃

原因:使用了原生 prompt/confirm/alert 或 select 元素。

解决:所有对话框和下拉选择均使用自定义 div 实现,完全规避鸿蒙 SubWindow 兼容问题。

六、总结

本文完整记录了 H2 SQL 工作台从开发到部署到鸿蒙 PC 平台的全过程。核心实现包括:

  1. 内存 SQL 引擎:支持 CREATE/INSERT/SELECT/UPDATE/DELETE/DROP/SHOW TABLES/DESCRIBE 八种 SQL 语句,含 WHERE 条件、ORDER BY 排序、LIMIT 限制
  2. Navicat 风格交互:双类型标签系统(sql/table),点击对象树结构或数据节点新开标签页展示可编辑网格
  3. 可编辑数据网格:单元格点击编辑、Enter 保存、Escape 取消、底部空行新增记录
  4. SQL 注释解析:正确处理 -- 单行注释,避免解析报错
  5. Catppuccin Mocha 深色主题:统一的 CSS 变量配色方案
  6. 鸿蒙三防策略:禁用原生对话框、原生 select、setWindowOpenHandler,全部使用自定义 div 替代

整个应用共 5 个文件、约 2160 行代码,零外部依赖,纯原生实现。

相关推荐
wei_shuo1 天前
Flutter 三方库 OpenHarmony 鸿蒙适配实战:in_app_update 应用内更新插件 ArkTS 原生适配全流程
openharmony·in_app_update
Java的搬运工3 天前
HarmonyOS ArkUI V2 实战:Schema 驱动表单、2in1 适配与实时通信
harmonyos·arkts·openharmony·harmonyos next·表单校验·arkui v2·2in1
熊猫钓鱼>_>4 天前
Flutter app_settings 鸿蒙适配实战:Intent 体系到 Want 的跨越
flutter·华为·harmonyos·openharmony·intent·want
哈__5 天前
Flutter 3.44.9 + OpenHarmony7:image_cropper三方库 图片裁剪的应用
flutter·openharmony
moyh-blog24 天前
OpenHarmony播放音乐start请求从media_service到audio_server的调用流程02
openharmony·audio
Industio_触觉智能1 个月前
在瑞芯微RK平台下的开源鸿蒙OpenHarmony分区镜像/散包固件烧录
嵌入式硬件·openharmony·烧录·开源鸿蒙·瑞芯微·鸿蒙开发板·鸿蒙嵌入式
nullregedit1 个月前
OpenHarmony 小鸿 AI 开发实战 10:CI1302 语音上行的 UART 帧、Opus 与 VAD
openharmony·语音交互·开源鸿蒙·ws63·ci1302
nullregedit2 个月前
OpenHarmony 小鸿 AI 开发实战 04:WS63 BurnTool 烧录、Loader、导出与回滚
openharmony·嵌入式开发·开源鸿蒙·ws63·burntool
nullregedit2 个月前
OpenHarmony 小鸿 AI 开发实战 05:烧录成功仍黑屏的 GPIO14/PWR_ON 故障复盘
openharmony·gpio·故障排查·开源鸿蒙·ws63