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 平台的全过程。核心实现包括:
- 内存 SQL 引擎:支持 CREATE/INSERT/SELECT/UPDATE/DELETE/DROP/SHOW TABLES/DESCRIBE 八种 SQL 语句,含 WHERE 条件、ORDER BY 排序、LIMIT 限制
- Navicat 风格交互:双类型标签系统(sql/table),点击对象树结构或数据节点新开标签页展示可编辑网格
- 可编辑数据网格:单元格点击编辑、Enter 保存、Escape 取消、底部空行新增记录
- SQL 注释解析:正确处理 -- 单行注释,避免解析报错
- Catppuccin Mocha 深色主题:统一的 CSS 变量配色方案
- 鸿蒙三防策略:禁用原生对话框、原生 select、setWindowOpenHandler,全部使用自定义 div 替代
整个应用共 5 个文件、约 2160 行代码,零外部依赖,纯原生实现。