InterModeller 是一款经典的概念建模工具,广泛应用于教育场景中的知识分类、概念关系梳理和决策建模。本文记录在鸿蒙 PC 平台上,基于 Electron 壳方案从零构建一个 InterModeller 概念建模应用的完整过程——涵盖 SVG 水平树形布局算法、思维导图风格深度颜色编码、双击内联编辑、缩放平移画布、三视图联动(树形/属性表/规则)、本地 JSON 持久化等核心功能的实现细节与踩坑经验。

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

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

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

一、技术架构分析

1.1 整体架构

本项目采用 Electron Web 层 + 鸿蒙 HAP 壳工程 的分层架构:

  • Electron Web 层:运行于 ArkWeb 引擎,提供完整的概念建模能力(树形分类视图、属性因子表、IF-THEN 规则视图、节点增删改查)
  • 鸿蒙 HAP 壳工程:通过 web_engine 模块加载 Web 应用,提供窗口管理、文件系统访问、屏幕自适应等系统能力

这种架构的核心优势在于:业务代码与平台完全解耦,Electron 部分可以在任何平台独立运行,鸿蒙壳仅负责适配和打包。

1.2 技术方案

对比项InterModeller 原版鸿蒙适配版
技术框架KDE/Qt (C++)HTML/CSS/Vanilla JavaScript
树形渲染Qt GraphicsViewSVG 水平树(思维导图风格)
数据存储本地文件本地 JSON 文件持久化
主题风格KDE 系统主题Catppuccin Mocha 深色主题
交互方式右键菜单 + 对话框双击内联编辑 + 自定义模态框
部署方式Linux 安装HAP 包(鸿蒙应用)
运行环境Linux 桌面鸿蒙 PC(ArkWeb 引擎)

1.3 核心功能清单

功能模块具体能力
树形视图SVG 水平树 + 贝塞尔曲线连线 + 6 级深度颜色 + 双击内联编辑
缩放平移Ctrl+滚轮缩放(25%~300%)、拖拽空白区域平移、适应视图一键归位
属性表自动收集全部节点特征,生成概念×属性交叉矩阵(✓/— 标记)
规则视图递归遍历分类路径,自动生成 IF-AND-THEN 规则列表
节点操作添加子节点、添加兄弟节点、编辑节点(名称/描述/特征)、删除节点
模型管理新建模型、删除模型、切换模型、本地持久化保存/加载
种子数据首次启动自动生成 6 个默认分类模型(动物/交通/植物/几何/编程/文件)

二、环境准备

2.1 开发环境要求

项目版本/信息
操作系统Windows 10/11
核心框架Electron (Node.js + Chromium)
技术栈HTML5/CSS3/SVG/Vanilla JavaScript
数据存储本地 JSON 文件(fs 读写)
目标设备鸿蒙 PC
目标架构arm64-v8a
开发工具DevEco Studio(鸿蒙官方 IDE)

2.2 项目结构

ohos_hap/
├── electron-apps/
│   └── InterModeller/                     # InterModeller 应用源码(开发目录)
│       ├── main.js                        # Electron 主进程(窗口管理 + 模型持久化 + IPC)
│       ├── renderer.js                    # 渲染进程(SVG 树渲染 + 内联编辑 + 三视图联动)
│       ├── index.html                     # HTML 页面布局(工具栏 + 侧栏 + 三视图 + 模态框)
│       ├── package.json                   # 项目配置
│       └── styles/
│           └── intermodeller.css          # Catppuccin Mocha 深色主题 + 深度颜色编码
├── web_engine/                            # 鸿蒙 web_engine 模块
│   └── src/main/resources/
│       └── resfile/resources/app/         # 部署目录(构建时打包进 HAP)
│           ├── main.js
│           ├── renderer.js
│           ├── index.html
│           ├── package.json
│           └── styles/intermodeller.css
└── electron/                              # Electron 原生库
    └── libs/arm64-v8a/
        ├── libelectron.so                 # Electron 核心库
        ├── libadapter.so                  # 适配层库
        └── libffmpeg.so                   # 多媒体库

开发流程:在 electron-apps/InterModeller/ 中开发,每次修改后同步到 web_engine/src/main/resources/resfile/resources/app/ 部署目录。

三、核心适配流程

3.1 第一步:创建 Electron 主进程

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

主进程负责窗口创建、模型数据持久化和 IPC 通信。InterModeller 的 IPC 通道非常精简,仅需两个通道完成模型数据的保存和加载。

// InterModeller 概念建模工具 主进程
const { app, BrowserWindow, ipcMain, screen } = require('electron');
const fs = require('fs');
const path = require('path');

app.disableHardwareAcceleration();

let mainWindow = null;

// 模型持久化存储
const modelsPath = path.join(app.getPath('userData'), 'intermodeller-models.json');

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(); });

// ========== IPC 通道 ==========

// 保存模型
ipcMain.handle('model:save', async (event, models) => {
  try {
    fs.writeFileSync(modelsPath, JSON.stringify(models, null, 2), 'utf-8');
    return { success: true };
  } catch (e) {
    return { success: false, message: e.message };
  }
});

// 加载模型
ipcMain.handle('model:load', async () => {
  try {
    if (fs.existsSync(modelsPath)) {
      const models = JSON.parse(fs.readFileSync(modelsPath, 'utf-8'));
      return { success: true, models: models };
    }
  } catch (e) { /* 首次启动无文件 */ }
  return { success: true, models: null };
});

鸿蒙三防策略说明

防护目标配置项作用
防 XComponent 崩溃frame: true, transparent: false, resizable: true使用标准窗口模式,避免触发鸿蒙适配层缺陷
防 GPU 白屏disableHardwareAcceleration()禁用硬件加速,规避 GPU 渲染异常
防单点故障try-catch 包裹窗口创建即使窗口创建失败也不会导致进程崩溃

3.2 第二步:6 个默认种子模型

InterModeller 的核心价值在于概念建模,首次启动时预置 6 个不同领域的分类模型,让用户立即体验功能而无需从零创建:

模型名称分类对象树深度节点数
动物分类脊椎动物(哺乳/鸟类/爬行)4 级14 个
交通工具海陆空交通方式3 级11 个
植物分类被子/裸子植物4 级11 个
几何图形多边形与圆形4 级11 个
编程语言编译型/解释型3 级8 个
文件格式文本/二进制文件4 级14 个

每个模型的节点都带有特征属性(如"温血"“胎生”“哺乳”),这些属性在属性表和规则视图中自动聚合展示。

// 动物分类模型示例(含特征属性)
var DEFAULT_MODELS = [
  {
    id: 'demo-animal', name: '动物分类', description: '脊椎动物分类模型',
    createdAt: Date.now(),
    root: {
      id: genId(), name: '动物', description: '', attributes: [],
      children: [
        { id: genId(), name: '哺乳动物', attributes: ['温血', '胎生', '哺乳'],
          children: [
            { id: genId(), name: '猫科', attributes: ['可伸缩爪'],
              children: [
                { id: genId(), name: '猫', attributes: ['小型', '宠物'], children: [] },
                { id: genId(), name: '狮', attributes: ['大型', '野生'], children: [] }
              ]},
            { id: genId(), name: '犬科', attributes: ['群居'],
              children: [
                { id: genId(), name: '狼', attributes: ['野生'], children: [] },
                { id: genId(), name: '狗', attributes: ['宠物'], children: [] }
              ]}
          ]},
        { id: genId(), name: '鸟类', attributes: ['有羽毛', '卵生', '温血'],
          children: [
            { id: genId(), name: '鹰', attributes: ['猛禽', '大型'], children: [] },
            { id: genId(), name: '麻雀', attributes: ['小型'], children: [] }
          ]},
        { id: genId(), name: '爬行动物', attributes: ['冷血', '有鳞', '卵生'],
          children: [
            { id: genId(), name: '蛇', attributes: ['无足'], children: [] },
            { id: genId(), name: '龟', attributes: ['有壳'], children: [] }
          ]}
      ]
    }
  }
  // ... 其余 5 个模型结构类似
];

3.3 第三步:构建页面布局

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

页面采用经典的三栏布局:顶部工具栏 + 左侧边栏(模型列表/属性面板)+ 右侧主内容区(三视图切换)。

<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <title>InterModeller - 概念建模工具</title>
  <link rel="stylesheet" href="styles/intermodeller.css">
</head>
<body>
  <!-- 顶部工具栏 -->
  <div class="toolbar">
    <div class="toolbar-brand">🧩 InterModeller</div>
    <div class="toolbar-actions">
      <button class="tool-btn" id="btnNewModel">📄 新建</button>
      <button class="tool-btn" id="btnSaveModel">💾 保存</button>
      <div class="toolbar-sep"></div>
      <button class="tool-btn" id="btnAddChild">➕ 子节点</button>
      <button class="tool-btn" id="btnAddSibling">➕ 兄弟</button>
      <button class="tool-btn" id="btnEditNode">✏️ 编辑</button>
      <button class="tool-btn" id="btnDeleteNode">🗑️ 删除</button>
      <div class="toolbar-sep"></div>
      <button class="tool-btn" id="btnExpandAll">📂 展开</button>
      <button class="tool-btn" id="btnCollapseAll">📁 折叠</button>
      <div class="toolbar-sep"></div>
      <button class="tool-btn" id="btnZoomOut"></button>
      <span class="zoom-level" id="zoomLevel">100%</span>
      <button class="tool-btn" id="btnZoomIn"></button>
      <button class="tool-btn" id="btnZoomReset"></button>
      <button class="tool-btn" id="btnFitView">⊞ 适应</button>
    </div>
  </div>

  <!-- 主工作区:侧边栏 + 三视图 -->
  <div class="workspace">
    <!-- 左侧边栏 -->
    <div class="sidebar" id="sidebar">
      <div class="sidebar-section">
        <div class="sidebar-header"><span class="sidebar-title">模型列表</span></div>
        <div class="model-list" id="modelList"></div>
      </div>
      <div class="sidebar-section flex-grow">
        <div class="sidebar-header"><span class="sidebar-title">节点属性</span></div>
        <div class="props-panel" id="propsPanel"></div>
      </div>
    </div>
    <div class="sidebar-resizer" id="sidebarResizer"></div>

    <!-- 右侧三视图 -->
    <div class="main-area">
      <div class="content-tabs" id="viewTabs">
        <div class="content-tab active" data-view="tree">树形视图</div>
        <div class="content-tab" data-view="table">属性表</div>
        <div class="content-tab" data-view="rules">规则视图</div>
      </div>
      <!-- 树形视图(SVG 画布 + 缩放视口) -->
      <div class="view-panel active" id="viewTree">
        <div class="tree-viewport" id="treeViewport">
          <svg class="tree-canvas" id="treeCanvas"></svg>
        </div>
        <div class="tree-hint" id="treeHint">
          <span>双击节点编辑名称 · 滚轮缩放 · 拖拽空白区域平移</span>
        </div>
      </div>
      <!-- 属性表视图 -->
      <div class="view-panel" id="viewTable">
        <div class="table-container" id="tableContainer"></div>
      </div>
      <!-- 规则视图 -->
      <div class="view-panel" id="viewRules">
        <div class="rules-container" id="rulesContainer"></div>
      </div>
    </div>
  </div>

  <!-- 底部状态栏 -->
  <div class="statusbar">
    <span id="statusLeft">就绪</span>
    <span id="statusRight">InterModeller v1.0 | 双击节点可直接编辑</span>
  </div>

  <script src="renderer.js"></script>
</body>
</html>

页面设计要点:

  • 工具栏集成缩放控件(放大/缩小/重置/适应视图),无需弹出菜单
  • 侧边栏分上下两区:模型列表和节点属性面板,中间有拖动条可调节宽度
  • 树形视图包裹在 tree-viewport 容器中,支持溢出滚动和拖拽平移
  • 三视图通过 content-tab 标签切换,默认显示树形视图
  • 工具栏右侧包含模型名称徽章、节点计数器和侧边栏折叠按钮(toolbar-info 区域)
  • 页面底部包含两个模态框:新建模型对话框和编辑节点对话框,均使用纯 HTML/CSS 实现,避免触发鸿蒙 SubWindow 崩溃

四、思维导图风格树形视图

这是 InterModeller 最核心的功能模块。树形视图采用 SVG 渲染,从左到右水平展开,类似思维导图工具。

4.1 水平树形布局算法

布局算法采用递归计算方式,每个节点根据子树的尺寸计算自己的分配区域:

// 布局常量
var NODE_W = 140, NODE_H = 34, H_GAP = 60, V_GAP = 8;

// 水平布局:width=水平总跨度,height=垂直总跨度
function calcLayout(node, depth) {
  // 叶子节点或折叠节点
  if (!node.children || node.children.length === 0 || node.collapsed) {
    return { width: NODE_W, height: NODE_H };
  }
  // 递归计算所有子节点的布局
  var childLayouts = [];
  var totalChildH = 0;
  for (var i = 0; i < node.children.length; i++) {
    var cl = calcLayout(node.children[i], depth + 1);
    childLayouts.push(cl);
    totalChildH += cl.height;
  }
  totalChildH += (node.children.length - 1) * V_GAP;
  // 取最大子树宽度(水平方向)
  var maxChildW = 0;
  for (var i = 0; i < childLayouts.length; i++) {
    if (childLayouts[i].width > maxChildW) maxChildW = childLayouts[i].width;
  }
  return {
    width: NODE_W + H_GAP + maxChildW,    // 自身宽度 + 间距 + 最大子树宽度
    height: Math.max(NODE_H, totalChildH), // 至少 NODE_H,否则取子树总高度
    childLayouts: childLayouts
  };
}

算法核心逻辑:

  • 水平方向:节点宽度 + 水平间距 + 最大子树宽度(取最深分支的宽度)
  • 垂直方向:所有子树高度之和 + 间距之和,与 NODE_H 取较大值
  • 子节点在父节点的分配区域内垂直居中排列

4.2 SVG 渲染与父节点居中

树形渲染的关键问题是父节点必须在其子树区域内垂直居中,否则贝塞尔曲线连线会产生错位。

function renderNodeSvg(node, x, y, html, depth) {
  var layout = calcLayout(node, depth);
  var isSelected = node.id === selectedNodeId;
  var depthClass = 'tree-node-depth-' + Math.min(depth, 5);
  var groupClass = 'tree-node-group ' + depthClass + (isSelected ? ' selected' : '');
  // 节点在其分配区域内垂直居中
  var nodeY = y + (layout.height - NODE_H) / 2;
  // 子节点连线(先画线后画节点,确保节点在上层)
  if (node.children && !node.collapsed) {
    var childY = y + (layout.height - getTotalChildH(node)) / 2;
    for (var i = 0; i < node.children.length; i++) {
      var cl = calcLayout(node.children[i], depth + 1);
      var childCY = childY + cl.height / 2;
      var linkClass = 'tree-link depth-' + Math.min(depth, 5);
      html += '<path class="' + linkClass + '" d="M' + (x + NODE_W) + ',' + (nodeY + NODE_H / 2) +
        ' C' + (x + NODE_W + H_GAP * 0.5) + ',' + (nodeY + NODE_H / 2) + ' ' +
        (x + NODE_W + H_GAP * 0.5) + ',' + childCY + ' ' +
        (x + NODE_W + H_GAP) + ',' + childCY + '"/>';
      html = renderNodeSvg(node.children[i], x + NODE_W + H_GAP, childY, html, depth + 1);
      childY += cl.height + V_GAP;
    }
  }
  // 节点矩形(居中位置)
  html += '<g class="' + groupClass + '" data-node-id="' + node.id + '">';
  html += '<rect class="tree-node-rect" x="' + x + '" y="' + nodeY + '" width="' + NODE_W + '" height="' + NODE_H + '"/>';
  var maxChars = depth === 0 ? 8 : 10;
  var displayText = node.name.length > maxChars ? node.name.substring(0, maxChars - 1) + '…' : node.name;
  html += '<text class="tree-node-text" x="' + (x + NODE_W / 2) + '" y="' + (nodeY + NODE_H / 2) + '" text-anchor="middle">' + escapeHtml(displayText) + '</text>';
  if (node.children && node.children.length > 0 && node.collapsed) {
    html += '<text class="tree-node-badge" x="' + (x + NODE_W - 6) + '" y="' + (nodeY + 12) + '">+' + node.children.length + '</text>';
  }
  html += '</g>';
  return html;
}

居中公式解析:nodeY = y + (layout.height - NODE_H) / 2

  • 叶子节点:layout.height = NODE_H,所以 nodeY = y(无偏移)
  • 有子节点:节点居中于子树分配区域中央,连线起点对齐子节点中心

贝塞尔曲线使用两个控制点实现平滑的 S 型连接:

  • CP1:从父节点右侧水平延伸(保持水平切线方向)
  • CP2:在子节点左侧水平方向(保持水平进入)

4.3 深度颜色编码

树形视图采用 6 级深度颜色编码,从根节点到叶子节点依次变化,直观展示分类层级:

深度颜色CSS 变量视觉效果
0(根)紫色–mauve最醒目,标识分类起点
1蓝色–blue一级分类
2青色–teal二级分类
3绿色–green三级分类
4桃色–peach四级分类
5黄色–yellow五级及以下
/* 深度颜色 - 节点矩形 */
.tree-node-depth-0 .tree-node-rect { fill: #45475a; stroke: var(--mauve); stroke-width: 2; }
.tree-node-depth-0 .tree-node-text { fill: var(--mauve); font-weight: 700; font-size: 14px; }
.tree-node-depth-1 .tree-node-rect { fill: #2a3a52; stroke: var(--blue); stroke-width: 1.5; }
.tree-node-depth-1 .tree-node-text { fill: var(--blue); }
.tree-node-depth-2 .tree-node-rect { fill: #243d3a; stroke: var(--teal); stroke-width: 1.5; }
.tree-node-depth-2 .tree-node-text { fill: var(--teal); }
.tree-node-depth-3 .tree-node-rect { fill: #2d3a2a; stroke: var(--green); stroke-width: 1.5; }
.tree-node-depth-3 .tree-node-text { fill: var(--green); }
.tree-node-depth-4 .tree-node-rect { fill: #3d3428; stroke: var(--peach); stroke-width: 1.5; }
.tree-node-depth-4 .tree-node-text { fill: var(--peach); }
.tree-node-depth-5 .tree-node-rect { fill: #3a3028; stroke: var(--yellow); stroke-width: 1.5; }
.tree-node-depth-5 .tree-node-text { fill: var(--yellow); }

连线颜色与节点深度同步,根节点的连线使用紫色且加粗(stroke-width: 2),其他层级使用对应的淡色连线。

4.4 双击内联编辑

思维导图工具的核心交互是双击节点直接编辑。InterModeller 通过在 SVG 节点位置覆盖 HTML input 元素实现:

function startInlineEdit(nodeId, groupEl) {
  var model = getActiveModel();
  if (!model) return;
  var node = findNode(model.root, nodeId);
  if (!node) return;
  var rect = groupEl.querySelector('.tree-node-rect');
  if (!rect) return;
  var bbox = rect.getBoundingClientRect();
  var viewport = getElementById('treeViewport');
  var vpRect = viewport.getBoundingClientRect();
  // 移除已有编辑器
  var old = getElementById('treeInlineEditor');
  if (old) old.remove();
  var input = document.createElement('input');
  input.type = 'text';
  input.className = 'tree-inline-editor';
  input.id = 'treeInlineEditor';
  input.value = node.name;
  input.style.left = (bbox.left - vpRect.left + viewport.scrollLeft) + 'px';
  input.style.top = (bbox.top - vpRect.top + viewport.scrollTop) + 'px';
  input.style.width = Math.max(bbox.width, 80) + 'px';
  input.style.height = bbox.height + 'px';
  input.style.lineHeight = bbox.height + 'px';
  viewport.appendChild(input);
  input.focus();
  input.select();
  function commit() {
    var newName = input.value.trim();
    if (newName && newName !== node.name) {
      node.name = newName;
      saveModels();
      showNotification('编辑', '节点已更新: ' + newName, 'success');
    }
    if (input.parentNode) input.remove();
    renderTree();
    renderProps();
  }
  input.addEventListener('keydown', function(e) {
    if (e.key === 'Enter') { e.preventDefault(); commit(); }
    else if (e.key === 'Escape') { input.remove(); }
  });
  input.addEventListener('blur', function() { commit(); });
}

坐标转换的关键:SVG 元素经过缩放后,getBoundingClientRect() 返回的是屏幕像素坐标,需要减去视口容器的偏移量并加上滚动量,才能正确定位 input 覆盖在节点上方。

4.5 缩放与平移

树形视图支持三种交互方式:

Ctrl+滚轮缩放:缩放范围 25%~300%,步进 0.15

viewport.addEventListener('wheel', function(e) {
  if (e.ctrlKey || e.metaKey) {
    e.preventDefault();
    var delta = e.deltaY > 0 ? -0.1 : 0.1;
    treeZoom = Math.max(0.25, Math.min(3, treeZoom + delta));
    renderTree();
  }
}, { passive: false });

拖拽平移:在空白区域按住鼠标拖动画布

viewport.addEventListener('mousedown', function(e) {
  if (e.target.closest('.tree-node-group')) return; // 不在节点上拖拽
  isPanning = true;
  panStart = { x: e.clientX, y: e.clientY,
    scrollLeft: viewport.scrollLeft, scrollTop: viewport.scrollTop };
  viewport.classList.add('grabbing');
});
document.addEventListener('mousemove', function(e) {
  if (!isPanning) return;
  viewport.scrollLeft = panStart.scrollLeft - (e.clientX - panStart.x);
  viewport.scrollTop = panStart.scrollTop - (e.clientY - panStart.y);
});

适应视图:一键缩放至恰好显示全部内容

function fitView() {
  var model = getActiveModel();
  if (!model) return;
  var viewport = getElementById('treeViewport');
  var layout = calcLayout(model.root, 0);
  var pad = 40;
  var treeW = layout.width + pad * 2;
  var treeH = layout.height + pad * 2;
  var vpW = viewport.clientWidth;
  var vpH = viewport.clientHeight;
  var scaleX = vpW / treeW;
  var scaleY = vpH / treeH;
  treeZoom = Math.min(scaleX, scaleY, 2);
  treeZoom = Math.max(0.25, treeZoom);
  renderTree();
}

五、三视图联动

5.1 属性表视图(因子表)

属性表自动遍历整棵分类树,收集所有节点的特征属性,生成 概念×属性 交叉矩阵:

function collectTableData(node, path, allAttrs, rows) {
  var fullName = path ? path + ' > ' + node.name : node.name;
  if (node.attributes && node.attributes.length > 0) {
    for (var i = 0; i < node.attributes.length; i++) { allAttrs[node.attributes[i]] = true; }
    rows.push({ name: fullName, attrs: node.attributes.slice() });
  } else {
    rows.push({ name: fullName, attrs: [] });
  }
  if (node.children) {
    for (var c = 0; c < node.children.length; c++) {
      collectTableData(node.children[c], fullName, allAttrs, rows);
    }
  }
}

渲染结果是一个标准的因子表:每行是一个概念(带完整路径),每列是一个属性,✓ 表示具备该属性,— 表示不具备。表头使用 sticky 定位,滚动时始终可见。

5.2 规则视图(IF-THEN 推理)

规则视图递归遍历分类路径,将每个叶子节点的完整属性链转化为 IF-AND-THEN 规则:

function generateRules(node, conditions, rules) {
  var path = conditions.slice();
  // 累积当前节点的特征属性
  if (node.attributes) {
    for (var i = 0; i < node.attributes.length; i++) {
      if (path.indexOf(node.attributes[i]) < 0) path.push(node.attributes[i]);
    }
  }
  // 叶子节点生成规则
  if (!node.children || node.children.length === 0) {
    rules.push({ concept: node.name, conditions: path });
  } else {
    for (var c = 0; c < node.children.length; c++) {
      generateRules(node.children[c], path, rules);
    }
  }
}

以"动物分类"为例,生成的规则包括:

  • IF 温血 AND 胎生 AND 哺乳 AND 可伸缩爪 AND 小型 AND 宠物 THEN 猫
  • IF 温血 AND 胎生 AND 哺乳 AND 可伸缩爪 AND 大型 AND 野生 THEN 狮
  • IF 有羽毛 AND 卵生 AND 温血 AND 猛禽 AND 大型 THEN 鹰

5.3 视图切换机制

三个视图通过 content-tab 标签切换,使用 CSS classList.toggle 控制显隐,避免 inline style 冲突:

function switchView(viewName) {
  var tabs = getElementById('viewTabs').children;
  for (var i = 0; i < tabs.length; i++) {
    tabs[i].classList.toggle('active', tabs[i].dataset.view === viewName);
  }
  getElementById('viewTree').classList.toggle('active', viewName === 'tree');
  getElementById('viewTable').classList.toggle('active', viewName === 'table');
  getElementById('viewRules').classList.toggle('active', viewName === 'rules');
  // 按需渲染(属性表和规则视图仅在切换时重新渲染)
  if (viewName === 'table') renderTableView();
  if (viewName === 'rules') renderRulesView();
}

对应的 CSS 规则:

.view-panel { display: none; flex: 1; overflow: hidden; }
.view-panel.active { display: flex; }

六、鸿蒙平台特殊适配

6.1 自定义通知替代 alert

鸿蒙 Electron 适配层对原生 alert 和 confirm 的支持存在缺陷(会触发 SubWindow 崩溃),因此使用自定义的 HTML 通知组件替代:

function showNotification(title, message, type) {
  var existing = getElementById('imNotification');
  if (existing) existing.remove();
  var div = document.createElement('div');
  div.id = 'imNotification';
  div.className = 'im-notification' + (type === 'error' ? ' error' : type === 'success' ? ' success' : '');
  div.innerHTML = '<div class="notification-title">' + escapeHtml(title) + '</div>' +
    '<div class="notification-msg">' + escapeHtml(message) + '</div>';
  document.body.appendChild(div);
  setTimeout(function() { if (div.parentNode) div.remove(); }, 4000);
}

通知组件带有滑入动画,支持 error(红色边框)和 success(绿色边框)两种状态,4 秒后自动消失。

6.2 纯 HTML 模态框替代原生对话框

所有模态框(新建模型、编辑节点)均使用纯 HTML/CSS 实现,支持三种关闭方式:

  • 点击右上角 ✕ 按钮
  • 点击遮罩层空白区域
  • 按 Escape 键关闭

同时支持 Enter 键直接提交表单,提升操作效率。

6.3 禁止使用原生 select 元素

鸿蒙平台的原生 select 元素会触发 SubWindow 崩溃,InterModeller 全程使用自定义 UI 组件替代,包括:

  • 视图切换:content-tab 标签(HTML div + click 事件)
  • 节点编辑:自定义模态框(HTML form + CSS 覆盖层)
  • 通知提示:自定义 notification 组件(HTML div + CSS 动画)

七、Catppuccin Mocha 深色主题

InterModeller 采用 Catppuccin Mocha 配色方案,通过 20 个 CSS 变量统一管理:

:root {
  --base: #1e1e2e;          /* 主背景 */
  --mantle: #181825;         /* 侧栏/工具栏背景 */
  --crust: #11111b;          /* 标签栏/状态栏背景 */
  --surface0: #313244;       /* 输入框/悬停背景 */
  --surface1: #45475a;       /* 按钮激活/滚动条 */
  --surface2: #585b70;       /* 滚动条悬停 */
  --text: #cdd6f4;           /* 主文字颜色 */
  --text-muted: #a6adc8;     /* 辅助文字颜色 */
  --overlay0: #6c7086;       /* 占位符/角标 */
  --blue: #89b4fa;           /* 选中/高亮/一级深度 */
  --green: #a6e3a1;          /* 成功/三级深度 */
  --red: #f38ba8;            /* 错误/删除 */
  --yellow: #f9e2af;         /* 五级深度/缩放比例 */
  --mauve: #cba6f7;          /* 品牌色/根节点 */
  --peach: #fab387;          /* 四级深度 */
  --teal: #94e2d5;           /* 二级深度 */
  --sky: #89dceb;            /* 补充色 */
  --pink: #f5c2e7;           /* 补充色 */
  --border: #313244;         /* 默认边框 */
  --border-light: #45475a;   /* 悬停边框 */
}

主题设计原则:

  • 工具栏和侧边栏使用 mantle(略深于主背景),形成层次感
  • 选中的节点使用白色描边(stroke: #f5f5f5),在深色背景上高亮
  • 输入框聚焦时显示蓝色光晕(box-shadow),提供输入反馈
  • 滚动条使用 6px 窄条,不干扰内容区域

八、构建与部署

8.1 同步到 web_engine

开发完成后,将 electron-apps/InterModeller/ 目录的全部内容同步到 web_engine 部署目录:

# 清空旧文件
Remove-Item "web_engine\src\main\resources\resfile\resources\app\*" -Recurse -Force

# 复制最新文件
Copy-Item "electron-apps\InterModeller\*" `
  -Destination "web_engine\src\main\resources\resfile\resources\app\" `
  -Recurse -Force

注意:每次修改代码后都必须同步,否则构建的 HAP 包不会包含最新代码。

8.2 构建 HAP 包

在 DevEco Studio 中打开项目,选择 Build → Build Hap(s)/APP(s) → Build Hap(s),即可生成可安装的 HAP 包。

九、常见问题与解决方案

Q1:树形视图贝塞尔曲线连线错位

问题现象:父节点和子节点之间的连线明显扭曲,不是平滑的水平 S 型,尤其当子树较深时错位更严重。

根本原因:renderNodeSvg 中父节点画在分配区域顶部(y),而子节点组居中在分配区域内,导致连线起点和终点的 Y 坐标不在同一水平线上。以"动物分类"为例,"哺乳动物"节点分配区域高度为 324px,但节点只画在最顶部,节点中心在 y=97,而子节点组中心在 y=241——偏差 144px。

错误代码

// ❌ 父节点画在分配区域顶部,连线起点偏移
function renderNodeSvg(node, x, y, html, depth) {
  var layout = calcLayout(node, depth);
  // ... 连线起点用 y + NODE_H / 2,即分配区域顶部
  html += '<path d="M' + (x + NODE_W) + ',' + (y + NODE_H / 2) + ' ...';
  // ... 节点矩形也画在顶部
  html += '<rect x="' + x + '" y="' + y + '" ...';
  html += '<text y="' + (y + NODE_H / 2) + '" ...';
}

修复代码

// ✅ 父节点在其分配区域内垂直居中
function renderNodeSvg(node, x, y, html, depth) {
  var layout = calcLayout(node, depth);
  // 居中公式:分配区域高度减去节点高度,除以 2
  var nodeY = y + (layout.height - NODE_H) / 2;
  // 连线起点用 nodeY + NODE_H / 2,与子节点中心对齐
  html += '<path d="M' + (x + NODE_W) + ',' + (nodeY + NODE_H / 2) + ' ...';
  // 节点矩形也画在居中位置
  html += '<rect x="' + x + '" y="' + nodeY + '" ...';
  html += '<text y="' + (nodeY + NODE_H / 2) + '" ...';
}

Q2:calcLayout 高度计算只取第一个子节点导致树被裁切

问题现象:3 级以上的分类树底部被裁切,“鸟”"爬行动物"等分支看不到。

根本原因:初版 calcLayout 计算子树总高度时只取了 childLayouts[0].height(第一个子节点的高度),而非累加所有子节点高度。当第一个分支比后面的分支短时,viewBox 高度不足,后面的分支溢出。

错误代码

// ❌ 只取第一个子节点高度,后面的分支全部丢失
function calcLayout(node, depth) {
  // ... 递归计算子节点
  return {
    width: NODE_W + H_GAP + maxChildW,
    height: Math.max(NODE_H, childLayouts[0].height), // 只取第一个!
    childLayouts: childLayouts
  };
}

修复代码

// ✅ 累加所有子节点高度 + 间距
function calcLayout(node, depth) {
  if (!node.children || node.children.length === 0 || node.collapsed) {
    return { width: NODE_W, height: NODE_H };
  }
  var childLayouts = [];
  var totalChildH = 0;
  for (var i = 0; i < node.children.length; i++) {
    var cl = calcLayout(node.children[i], depth + 1);
    childLayouts.push(cl);
    totalChildH += cl.height;  // 累加每个子节点高度
  }
  totalChildH += (node.children.length - 1) * V_GAP; // 加上间距
  var maxChildW = 0;
  for (var i = 0; i < childLayouts.length; i++) {
    if (childLayouts[i].width > maxChildW) maxChildW = childLayouts[i].width;
  }
  return {
    width: NODE_W + H_GAP + maxChildW,
    height: Math.max(NODE_H, totalChildH),  // 取累加值与 NODE_H 的较大者
    childLayouts: childLayouts
  };
}

Q3:SVG 缩放后不显示

问题现象:Ctrl+滚轮缩放后,树形视图变成空白或只显示一部分。

根本原因:renderTree 中只设置了 viewBox 属性,未设置 width 和 height。浏览器在缩放时无法正确计算 SVG 的实际像素尺寸,导致渲染异常。

错误代码

// ❌ 只设 viewBox,未设 width/height
function renderTree() {
  var layout = calcLayout(root, 0);
  var vbW = Math.max(layout.width + 80, 600);
  var vbH = Math.max(layout.height + 80, 300);
  svg.setAttribute('viewBox', '0 0 ' + vbW + ' ' + vbH);
  // 缺少 width 和 height 设置
}

修复代码

// ✅ 同时设置 viewBox + width + height
function renderTree() {
  var layout = calcLayout(root, 0);
  var pad = 40;
  var vbW = Math.max(layout.width + pad * 2, 600);
  var vbH = Math.max(layout.height + pad * 2, 300);
  svg.setAttribute('viewBox', '0 0 ' + vbW + ' ' + vbH);
  svg.setAttribute('width', vbW * treeZoom);   // 实际像素宽度
  svg.setAttribute('height', vbH * treeZoom);  // 实际像素高度
}

配合 CSS 中 tree-canvas 的 min-width/min-height 设置,确保缩放后 SVG 正确填充视口:

.tree-canvas {
  min-width: 100%;
  min-height: 100%;
  display: block;
}

Q4:双击节点产生多个重叠的编辑器

问题现象:快速双击多个节点后,画布上出现多个重叠的 input 编辑框,无法正常使用。

根本原因:每次 dblclick 都创建新的 input 元素,没有移除之前创建的编辑器,导致多个编辑器叠加。

错误代码

// ❌ 每次双击都新建 input,旧的不清理
function startInlineEdit(nodeId, groupEl) {
  var bbox = rect.getBoundingClientRect();
  var input = document.createElement('input');
  input.className = 'tree-inline-editor';
  input.value = node.name;
  viewport.appendChild(input);
  // 如果连续双击 3 个节点,就有 3 个 input 重叠
}

修复代码

// ✅ 创建前先移除已有的编辑器
function startInlineEdit(nodeId, groupEl) {
  var model = getActiveModel();
  if (!model) return;
  var node = findNode(model.root, nodeId);
  if (!node) return;
  var rect = groupEl.querySelector('.tree-node-rect');
  if (!rect) return;
  var bbox = rect.getBoundingClientRect();
  var viewport = getElementById('treeViewport');
  var vpRect = viewport.getBoundingClientRect();
  // 关键:移除已有编辑器
  var old = getElementById('treeInlineEditor');
  if (old) old.remove();
  var input = document.createElement('input');
  input.type = 'text';
  input.className = 'tree-inline-editor';
  input.id = 'treeInlineEditor';  // 固定 ID,方便查找移除
  input.value = node.name;
  input.style.left = (bbox.left - vpRect.left + viewport.scrollLeft) + 'px';
  input.style.top = (bbox.top - vpRect.top + viewport.scrollTop) + 'px';
  input.style.width = Math.max(bbox.width, 80) + 'px';
  input.style.height = bbox.height + 'px';
  input.style.lineHeight = bbox.height + 'px';
  viewport.appendChild(input);
  input.focus();
  input.select();
}

Q5:模态框 Enter 键无法提交

问题现象:在新建模型或编辑节点对话框中填写完表单后,按 Enter 键无反应,必须用鼠标点击"创建"或"保存"按钮。

根本原因:keydown 事件监听中只处理了 Escape 键关闭模态框,没有处理 Enter 键提交。

错误代码

// ❌ 只处理了 Escape,缺少 Enter
document.addEventListener('keydown', function(e) {
  if (e.key === 'Escape') {
    hideModal('modalNewModel');
    hideModal('modalEditNode');
  }
});

修复代码

// ✅ Escape 关闭 + Enter 提交
document.addEventListener('keydown', function(e) {
  if (e.key === 'Escape') {
    hideModal('modalNewModel');
    hideModal('modalEditNode');
  }
  if (e.key === 'Enter') {
    // 判断当前哪个模态框可见,调用对应的提交函数
    if (getElementById('modalNewModel').style.display !== 'none') {
      createModel();
    } else if (getElementById('modalEditNode').style.display !== 'none') {
      saveNodeEdit();
    }
  }
});

十、总结

本文完整记录了 InterModeller 概念建模工具在鸿蒙 PC 平台的适配过程。核心技术要点总结如下:

技术点方案
树形布局SVG 水平树 + 递归 calcLayout + 父节点垂直居中
连线算法贝塞尔曲线(双控制点平滑 S 型)
深度编码6 级颜色(紫→蓝→青→绿→桃→黄)
内联编辑双击创建 HTML input 覆盖 SVG 节点
缩放平移Ctrl+滚轮缩放 + 拖拽空白区域平移 + 适应视图
三视图联动树形/属性表/规则视图通过 classList 切换
规则引擎递归遍历累积属性链 → IF-AND-THEN 规则
种子数据6 个默认分类模型(动物/交通/植物/几何/编程/文件)
数据持久化本地 JSON 文件(userData 目录)
鸿蒙稳定性三防策略 + 自定义组件替代原生弹窗/select
主题配色Catppuccin Mocha 深色主题(20 个 CSS 变量)

核心经验:鸿蒙 Electron 适配层目前对原生弹窗(confirm、alert)和原生下拉框(select)的支持存在限制,解决方案是从源头避免触发原生 SubWindow——用纯 HTML/CSS/JS 实现的自定义组件替代所有原生弹窗元素。这套"自定义组件替代"策略在 kdesvn、Kibana、Notepadqq 等多个应用中均已验证有效。

树形视图的父节点居中问题是一个容易被忽略但影响显著的布局缺陷——如果不做居中处理,连线虽然不会断裂,但视觉上会出现明显的错位感。通过 nodeY = y + (layout.height - NODE_H) / 2 这一行简单的修正,即可让所有节点和连线完美对齐,实现真正的思维导图效果。

整个适配过程遵循 Electron 壳方案标准化流程:在 electron-apps/ 开发目录中编写代码,通过同步命令部署到 web_engine/ 模块,最终由鸿蒙壳工程打包为 HAP 安装包。开发者可以专注于 Web 技术栈和概念建模功能实现,无需关心平台差异。

Logo

开源鸿蒙跨平台开发社区汇聚开发者与厂商,共建“一次开发,多端部署”的开源生态,致力于降低跨端开发门槛,推动万物智联创新。

更多推荐