
简介这是一款面向电力工程师的电力系统后台作图软件主要用于绘制设备布局与连接关系内置发电机、变压器、断路器、母线等丰富符号库可快速搭建准确的电力网络模型。压缩包共108个文件大小5.45MB包含C源代码obj、h、cpp、图形资源bmp、ico、cur以及工程配置与调试文件等其中源代码对理解电力符号绘制、图标动态闪烁、自动布线等核心功能极具参考价值。已有909人浏览学习适合电力系统设计人员、运维人员以及电力可视化软件开发者使用。资源提供完整工程和可执行程序可运行体验或二次开发其符号库、绘图模块及闪烁提示机制均可复用同时附带多种位图与图标素材方便直接用于电力监控界面定制有助于提升系统设计与故障排查效率。1. 电力系统后台作图软件给主接线图配上能拖拽、能接线的国标符号库在电力设计院和变电站班组里画一张35kV主接线图最痛的不是接线原理而是找符号、对对齐、调字体。A同学拿到这套电力系统后台作图软件时第一反应是终于有人把通用绘图软件里缺的那堆国标电力符号做成了代码。项目内置发电机、变压器、母线、断路器、隔离开关、接地、避雷器等符号按一次设备、二次设备、标注三类收纳拖进画布即可布图热点之间直接连线支持网格吸附、图层顺序、工程保存和SVG导出。它非常适合三类人正在做电网调度或变电主接线图编辑器的开发者想让学生快速出图的电力专业讲师以及打算在轻量制图内核上做二次开发的团队。这套工程的价值在于把“电力作图”这件过去重度依赖CAD软件的事搬到了浏览器里并且留好了扩展口。下面按我读源码的顺序从技术选型和架构设计开始拆再到核心模块、运行避坑和二次开发。2. 技术选型与整体架构为什么用SVG而不是Canvas工程数据为什么敢全存JSON2.1 技术栈选型SVG 画布加 Vue3 的实际理由先说这套工程的技术结论前端是 Vue3 TypeScript Vite渲染层直接用原生 SVG 操作不依赖第三方图形库状态管理用 Pinia后端是 Node.js Express数据库用 SQLite。整套组合的目标非常明确让拿到源码的人能在半小时内跑起来而不是先搭一套微服务再画第一根母线。为什么画布选 SVG 而不是 Canvas这个决定直接决定了项目的开发效率。电力系统图是强编辑场景每个符号要精确缩放到整数坐标选中后要显示连接热区悬停要变色拖拽时导线要实时跟着图元走。这些交互如果用 Canvas 做等于要自己重写一套命中检测和事件派发系统而 SVG 的每个图元天生是 DOM 节点事件、样式、层级操作全部走浏览器原生能力。另一个决定性原因在出图电力图纸经常要放大到 A0 打印Canvas 导出的是位图放大就虚SVG 序列化后是矢量文档转高清 PNG 或直接打印都稳。模块选型原因前端框架Vue3 TypeScript类型约束对坐标、图元模型这种强结构数据很重要构建工具Vite开发热更新快双击就能跑画布渲染原生 SVG符号精确缩放、事件命中、矢量导出状态管理Pinia画布数据全局共享比 props 层层传参干净后端Node.js Express和前端同语言符号渲染逻辑可复用数据库SQLite单文件部署工程数据量级完全够用这个选型也踩过坑才定下来。早期有人提过用 Electron 打包成桌面软件但电力系统的作图后台通常要嵌入到更大的管理平台里Web 形态反而更容易嵌。还有一版尝试过用 Canvas 做渲染写到连线联动时发现每次拖动都要手动算哪个图元被点中代码越来越像黑匣子才换回 SVG。2.2 架构分层一次作图会话在模块间怎么流转画布代码没有全堆在 Vue 组件里而是拆成了四层。视图层是 EditorView 和 SymbolPanel 这类组件只管 DOM 和事件绑定图元层是 registry 符号注册表和 renderer 渲染器负责把符号定义变成 SVG 节点交互层处理拖放、连线、属性编辑存储层是 serializer 加后端接口负责把画布变成 JSON、把 JSON 变回画布。一次完整的作图会话在模块间是这样流转的页面加载时EditorView 初始化画布 SVG把 viewBox 设为 0 0 1200 800SymbolPanel 遍历 registry 里的所有符号生成侧边栏图标。用户把断路器图标拖到画布上drop 事件触发后renderer 调用该符号的 build() 生成 SVG 节点push 进图元数组并把结构化数据写进 Pinia。选中线工具点两个图元的热区connection 模块生成一条 EdgeModel保存的是两端图元 id 和热区 id不保存死坐标。每次编辑动作结束serializer 把节点和边序列化成 JSON调后端接口落库。这种分层最直接的收益是数据和渲染分离。画布上拖拽的是 SVG 节点但所有业务逻辑操作的都是普通对象将来要加多人协作只需要在后端把 JSON 广播给其他客户端而不是去同步 DOM 树。项目里没有引入重型流程引擎或插件系统因为电力作图工具的复杂度主要在符号数量和坐标计算上四层已经够用。2.3 工程存储为什么一张表加一个 JSON 列就够了电力设备图元的结构高度异构断路器有分合状态和操作次数变压器有绕组和抽头信息标注类图元只有文字和字体。把这么多字段拆成几十张关系表对制图工具来说是给自己找麻烦。这套工程的数据库设计非常克制就一张 projects 表画布数据整个塞进一个 TEXT 列。CREATE TABLE projects ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, voltage_level TEXT DEFAULT 110kV, canvas_size TEXT DEFAULT 1200,800, data TEXT NOT NULL, created_at TEXT DEFAULT (datetime(now)), updated_at TEXT DEFAULT (datetime(now)) );data 字段存的是画布全量数据JSON 字符串格式。voltage_level 和 canvas_size 单独提出来是因为列表页和图纸标题栏要用不需要从整个 JSON 里解析。电压等级对电力作图还有一层实际作用符号颜色会跟随电压等级变化比如 110kV 一种配色、220kV 另一种配色把默认值放在工程表里方便整图统一换肤。data 字段内部的结构是约定好的节点和边分开存{ version: 1, canvas: { width: 1200, height: 800 }, symbols: [ { id: n1, type: breaker, x: 320, y: 240, rotate: 0 } ], edges: [ { id: e1, fromNode: n1, fromHotspot: right, toNode: n2, toHotspot: left } ] }symbols 数组里每个元素是一张图元快照type 字段对应 registry 里的符号类型x、y 是画布坐标rotate 是旋转角度。edges 数组不存折线的每个拐点只存起点和终点引用拐点由渲染器实时算。这样保存文件很小几百个图元的工程也就几十 KB。等工程版本多了再给这张表加一个 parent_id 做版本链即可当前这版不做是因为单机编辑场景用不上版本回滚。3. 核心模块拆解符号注册、拖拽放置、连线联动与导出链路3.1 符号库注册机制build 与 hotspots 两个约定打开 core/registry.ts这个文件是整个符号库的心脏。作者把所有电力符号收敛成一份 SymbolMeta 定义每种符号只需要实现两个东西build() 负责在 SVG 命名空间下创建图形节点hotspots() 返回连接点坐标。其他什么都不用管。export interface SymbolMeta { type: string; // 图元类型唯一标识如 breaker label: string; // 面板上显示的中文名 category: string; // 一次设备 / 二次设备 / 标注 voltage?: number; // 默认电压等级决定默认配色 build(): SVGGElement; // 创建图元 SVG 节点 hotspots(): HotSpot[]; // 返回电气连接热区 } const registry new Mapstring, SymbolMeta(); export function registerSymbol(meta: SymbolMeta) { registry.set(meta.type, meta); } export function getSymbol(type: string): SymbolMeta | undefined { return registry.get(type); }注册表用 Map 而不是数组原因很直接工程 JSON 里存的图元 type 是字符串加载工程时要用 type 反查符号定义Map 的取复杂度是 O(1)。SymbolPanel 渲染侧边栏时遍历 registry.values()renderer 放置图元时调 getSymbol(type).build()整个符号库和画布渲染完全解耦。新增符号只需要 registerSymbol 传一个对象不用改渲染器也不用改保存逻辑。hotspots() 的设计也值得说。它返回的 HotSpot 不是绝对坐标而是相对图元原点的偏移坐标比如一个断路器左边沿中点就是 { id: left, cx: 0, cy: 12 }。图元被拖到任意位置、任意旋转角度后连线模块会根据图元的 transform 矩阵把热区偏移换算成画布绝对坐标。这个约定让符号作者不用关心图元最终摆在哪只管把相对位置定义对。3.2 画布拖放drop 坐标换算与网格吸附拖放交互看着简单真正写起来第一个翻车点就是坐标换算。浏览器 drop 事件里的 clientX/clientY 是相对于视口的而 SVG 画布有滚动条、有缩放比例直接拿这个坐标 setAttribute 到 transform 上符号会偏到天涯海角。项目里用一个统一的换算函数解决。function toCanvasPoint(e: DragEvent): { x: number; y: number } { const rect svgRoot.getBoundingClientRect(); const scale currentZoom; // 当前画布缩放比例默认 1 const x (e.clientX - rect.left svgRoot.scrollLeft) / scale; const y (e.clientY - rect.top svgRoot.scrollTop) / scale; return snapToGrid({ x, y }, gridSize); } function snapToGrid(p: { x: number; y: number }, grid: number) { return { x: Math.round(p.x / grid) * grid, y: Math.round(p.y / grid) * grid, }; } canvas.addEventListener(drop, (e) { e.preventDefault(); const type e.dataTransfer.getData(text/symbol-type); if (!registry.has(type)) return; const group registry.get(type).build(); const { x, y } toCanvasPoint(e); group.setAttribute(transform, translate(${x}, ${y})); group.dataset.nid generateShortId(); // 业务主键不用 SVG id svgRoot.appendChild(group); editorStore.addNode({ id: group.dataset.nid, type, x, y }); });注意 dataset.nid 用的是业务主键而不是 SVG 原生 id。原因很实务一个画布上可能同时存在两个相同类型的图元如果用 type 当 id 必然冲突而 SVG id 是全局命名空间万一用户从别的工程粘贴图元过来重名会让 querySelector 拿到第一个匹配节点连线全乱。业务主键是序列化时生成的短 id工程保存、加载、连线查找都以它为准。snapToGrid 的参数 gridSize 默认是 10 像素。这一步看着不起眼实际对出图质量影响很大。电气图里导线必须准确接到开关桩头如果坐标里出现小数点导出 SVG 后设备边缘和导线之间会有零点几像素的缝隙打印出来就是一条细白线。网格吸附同时服务了编辑体验和导出精度。3.3 连线联动EdgeModel 如何绑定图元热区电力厂站图里的连线本质是从一个图元热区到另一个热区的折线不能像画示意图那样一段段手拼。项目把连线建模为引用关系EdgeModel 里存的是两端图元的 id 和热区 id图元移动后导线会自动重算。interface EdgeModel { id: string; fromNode: string; // 起点图元 id fromHotspot: string; // 起点热区 id toNode: string; // 终点图元 id toHotspot: string; // 终点热区 id color: string; lineWidth: number; } function refreshEdge(edge: EdgeModel, svgRoot: SVGSVGElement) { const fromGroup svgRoot.querySelector([data-nid${edge.fromNode}]); const toGroup svgRoot.querySelector([data-nid${edge.toNode}]); const a getHotspotPosition(fromGroup, edge.fromHotspot); const b getHotspotPosition(toGroup, edge.toHotspot); const path buildPolyline(a, b, 20); const pathEl svgRoot.querySelector(#edge-${edge.id}); pathEl.setAttribute(d, path); } function buildPolyline(a: Point, b: Point, margin: number): string { // 从起点水平引出直角转折再水平进入终点避免斜穿设备 const ax a.x margin; const bx b.x - margin; return M ${a.x} ${a.y} L ${ax} ${a.y} L ${bx} ${b.y} L ${b.x} ${b.y}; }refreshEdge 在每次图元移动结束的 mouseup 事件里由 store 统一派发不是拖动过程中高频触发。拖动过程中频繁改 path 会让低性能电脑掉帧松手后重算一次配合 CSS transition 给 path 加 0.1 秒过渡视觉上导线是跟着走的性能也没压力。buildPolyline 里先水平出线再直角转折是这个模块最有电力行业味道的地方。实际电力图纸中设备端子通常从左右两侧引出导线以水平和垂直线段为主极少斜线。直角折线更接近真实出图风格也避免导线从设备上方斜穿过去造成遮挡。参数 margin 是导线离开热区后先延伸的水平距离可以按符号尺寸调默认 20 像素效果最稳。提示refreshEdge 里用 querySelector 按>function exportSVG(): string { const clone svgRoot.cloneNode(true) as SVGSVGElement; clone.setAttribute(xmlns, http://www.w3.org/2000/svg); const styleXml collectGlobalStyles(); // 线宽、箭头、字体等共享样式 const inner new XMLSerializer().serializeToString(clone); return ?xml version1.0 encodingUTF-8?\n svg xmlnshttp://www.w3.org/2000/svg viewBox0 0 1200 800 style${styleXml}/style${extractBody(inner)}/svg; }这里有一个容易踩的坑也是我读代码时特别注意到的。直接把 SVG 节点的 outerHTML 拼接进字符串是不行的因为浏览器给 SVG 元素生成的序列化结果里属性顺序和命名空间都不可控中文标注还可能变成乱码。项目用 XMLSerializer 做标准序列化再手动包一层带 xmlns 声明的根节点这样导出的文件在任何浏览器里开都是合法 SVG。collectGlobalStyles 会把箭头 marker、虚线样式、设备配色这些全局定义内联进