尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

自建本地优先CRM工作台:Electron+SQLite的技术选型与落地实践

自建本地优先CRM工作台:Electron+SQLite的技术选型与落地实践 做这套系统之前先说明一下它的起点。当时我在整理客户资料的时候发现自己每天花在“找信息”上的时间远比“处理客户需求”的时间多。正好那段时间桌面端应用开发的环境也成熟了Electron 已经能很稳定地支撑这种偏工具型的产品所以我就决定把 DeskcommCRM 当成一个长期项目来做而不是临时脚本。这篇文章会把整个项目的思考过程、代码结构和落地细节都摊开讲包含技术选型的原因、数据表怎么设计、通信模块怎么做、以及我踩过的一些实在的坑。1. 确立边界DeskcommCRM 不是通用 CRM而是“个人通信工作台”1.1 从需求倒推本地优先沟通记录优先很多人一听到 CRM脑子里浮现的都是销售漏斗、商机阶段、团队业绩看板这些概念。但 DeskcommCRM 的定位完全不同它服务的对象是自由职业者、独立顾问、小型工作室这些“一个人就是一个业务团队”的用户。对这类用户来说CRM 中最有价值的资产不是销售预测而是历史沟通记录、客户背景和待办事项。我一开始也犯过一个典型错误就是想把市面上 CRM 的主流功能都塞进来比如自动化营销邮件、多渠道 API 集成、复杂的权限体系。结果画完产品原型后发现这个项目的复杂度已经超出了个人维护能力而且大部分功能根本用不上。最终我把需求收敛到一个核心命题让用户打开软件后能最快看到这个客户是谁上次聊了什么下一步该做什么。这听起来很简单但很多商业化 CRM 反而做不到。它们把联系人、邮件、任务拆成独立模块用户需要来回切换页面才能拼凑出完整的上下文。DeskcommCRM 的第一性设计原则就是“按客户聚合所有信息”而不是“按功能模块组织信息”。1.2 四个核心场景定下功能边界经过对真实使用场景的梳理我保留了以下四个核心能力客户信息管理记录联系人基础信息、来源渠道、标签、自定义字段满足“看一眼就知道客户大概情况”的需求。沟通记录聚合把邮件、即时聊天记录、电话纪要统一挂在同一个联系人下按时间倒序显示。待办与跟进给每个联系人创建跟进任务支持到期提醒做到“不漏人、不误事”。数据导入导出能快速从 Excel/CSV 导入存量客户也能随时导出备份避免被工具绑定。除此之外的功能比如发票管理、合同模板、营销活动我在第一版里有意忽略。这个决定很关键如果一开始就追求大而全项目大概率会烂尾。功能边界明晰之后后面所有的技术选型都有了解题方向它需要本地数据库、需要跨平台桌面能力、需要较好的文本处理能力但不需要服务器、不需要多人实时协同。这种“去服务端”的架构大幅降低了部署和隐私层面的负担。2. 技术选型与工程结构为什么是 Electron React SQLite2.1 本地数据是核心资产所以选 SQLite 而不是云数据库DeskcommCRM 既然定位在“本地优先”数据存储方案其实不用过多纠结。SQLite 是最合适的默认选项原因有三第一它是单文件数据库一个.db文件就能承载所有数据备份就是复制文件简单直接第二它在本地读取性能上有天然优势几万条客户记录和几十万条沟通日志的查询都是毫秒级第三不需要额外安装数据库服务用户拿到安装包就能跑这符合桌面工具“开箱即用”的预期。SQLite 有一个需要留意的地方它是嵌入式数据库不支持网络访问也不能像 MySQL 那样处理高并发的写入请求。但在桌面应用的单用户场景下这些缺点几乎不构成问题。真正需要注意的是并发锁桌面应用可能存在多个异步进程同时写库的情况我在后面“坑和教训”部分会专门展开。2.2 Electron 和 React 的分工主进程管数据渲染进程管界面Electron 的架构理解起来可以借用“前台和后台”的概念。渲染进程就是用户能看到的界面负责展示和交互相当于前台主进程运行在 Node.js 环境中负责操作数据库、调用系统能力相当于后台。前后台之间通过 IPC 通信而不是直接共享变量。我在 DeskcommCRM 里采用了清晰的分层职责主进程负责 SQLite 的读写、SMTP/IMAP 邮件收发、文件导入导出、系统托盘提醒。渲染进程负责客户列表、对话时间线、任务看板等 UI 组件所有数据都需要通过封装好的 API 向主进程请求。preload 脚本作为桥梁层向外暴露安全的 IPC 接口不通盘把 Node.js 能力暴露给前端降低安全风险。在 UI 框架选择上我用了 React 18 加 Vite 构建组件库选了 Ant Design。原因很实际Ant Design 的 Table、Form、Modal 组件对这类管理后台型的界面覆盖度非常高能省下大量造轮子的时间。状态管理用了 zustand因为项目规模不大Redux 的样板代码完全是负担。目录结构大致如下deskcomm-crm/ ├── electron/ │ ├── main.ts # 主进程入口 │ ├── db/ │ │ ├── index.ts # 数据库连接与迁移 │ │ ├── schema.sql # 建表语句 │ │ └── repositories/ # 数据访问层 │ └── services/ │ ├── mail.service.ts # 邮件收发 │ └── backup.service.ts ├── src/ │ ├── components/ # React 组件 │ ├── pages/ # 页面级组件 │ ├── stores/ # zustand 状态 │ └── api/ # 封装 IPC 调用 └── package.json这样分层之后前端开发时可以先用本地 Mock 数据跑界面后端开发则可以直接写 API 和命令行测试脚本调试数据库逻辑互不阻塞。这一点在个人项目中非常重要因为你没有团队分工的缓冲如果每一层耦合太深改一处就崩一片会严重消耗开发热情。2.3 为什么没用 Tauri 或纯 Web 方案这里值得展开说明一下因为我在开发过程中也评估过 Tauri 这个方案。Tauri 的包体积确实小内存占用也更低但它要求使用系统自带的 WebView不同系统上的兼容性差异会让界面表现不稳定。更关键的是Tauri 后端的 Rust 学习曲线对我来说过于陡峭DeskcommCRM 的核心逻辑是数据处理和通信集成用成熟生态的 Node.js 显然能更快出活。纯 Web 方案我也想过比如做一个浏览器访问的 SPA。但放弃私有化部署和本地文件访问能力意味着客户的数据要放在服务器上这对看重隐私的用户来说不是加分项。桌面应用即使功能简单一点只要能保证“数据只留在自己电脑里”就已经赢了信任感。最终 Electron 的成熟生态和跨平台稳定性让我没有犹豫太久。3. 客户数据模型设计从一张宽表到一套完整的关系结构3.1 表结构设计客户、联系人、互动记录、任务数据模型是业务落地的地基。DeskcommCRM 第一版的数据表一共有六张核心四张分别是customers、contacts、interactions、tasks。其余两张是tags和customer_tags用于多对多的标签关系。设计的时候我刻意把“客户”和“联系人”拆成两个概念。客户可以是公司或组织联系人则是客户下的具体人员。比如你对接的公司叫“云创科技”但实际联系的人是市场部的张经理和采购部的李主管这种一个客户多个联系人的场景非常常见。如果只建一张联系人表后续做公司维度的统计会很别扭。interactions表是整套系统的核心因为 DeskcommCRM 最看重的就是沟通记录。这张表记录每一次和客户的交互类型包括邮件、电话、会议、微信或企微沟通。在字段设计上我把类型、摘要、内容、关联联系人、关联客户、发生时间全部放在一张表里并用索引覆盖查询频率最高的customer_id occurred_at组合。任务的表设计相对灵活它关联到客户或联系人但不强制要求两者都有。这样可以覆盖“客户还没分配具体联系人先建一个跟进任务”的场景。任务状态拆成 pending、completed 和 cancelled截止时间单独建了索引因为提醒功能需要频繁查询“今天到期且未完成”的任务。3.2 迁移策略不用 ORM用原生 SQL 文件管理版本很多开发者在桌面应用里习惯用 TypeORM 或 Prisma 这类 ORM 做数据映射。但我个人建议在小项目中用原生 SQL 文件加版本号管理迁移反而比 ORM 更省心。ORM 在关联查询和自动建表上确实方便但它的抽象会让人慢慢失去对 SQL 执行过程的掌控感遇到复杂查询调试起来反而更吃力。DeskcommCRM 的迁移机制很轻量-- migration_001_init.sql CREATE TABLE IF NOT EXISTS customers ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, company TEXT, source TEXT, note TEXT, created_at TEXT NOT NULL DEFAULT (datetime(now)), updated_at TEXT NOT NULL DEFAULT (datetime(now)) ); CREATE TABLE IF NOT EXISTS contacts ( id INTEGER PRIMARY KEY AUTOINCREMENT, customer_id INTEGER REFERENCES customers(id) ON DELETE CASCADE, name TEXT NOT NULL, role TEXT, email TEXT, phone TEXT, created_at TEXT NOT NULL DEFAULT (datetime(now)) ); CREATE TABLE IF NOT EXISTS interactions ( id INTEGER PRIMARY KEY AUTOINCREMENT, customer_id INTEGER REFERENCES customers(id) ON DELETE CASCADE, contact_id INTEGER REFERENCES contacts(id) ON DELETE SET NULL, type TEXT NOT NULL CHECK (type IN (email, call, meeting, chat, other)), summary TEXT, content TEXT, occurred_at TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT (datetime(now)) ); CREATE TABLE IF NOT EXISTS tasks ( id INTEGER PRIMARY KEY AUTOINCREMENT, customer_id INTEGER REFERENCES customers(id) ON DELETE CASCADE, contact_id INTEGER REFERENCES contacts(id) ON DELETE SET NULL, title TEXT NOT NULL, due_date TEXT, status TEXT NOT NULL DEFAULT pending CHECK (status IN (pending, completed, cancelled)), created_at TEXT NOT NULL DEFAULT (datetime(now)) );迁移执行器用一个简单的migrations表记录当前版本每次启动时对比代码里的 SQL 文件版本号未执行过的文件就依次执行。这种方式的优点是透明、可控整个数据库的结构演进过程就是一批文本文件做代码审查和回溯都很方便。缺点是少了很多 ORM 提供的类型安全但对付桌面应用的单用户场景SQL 的可读性和调试效率已经足够。3.3 索引与查询优化的小细节SQLite 的索引设计和大型数据库思路相同但需要注意本地数据量的实际规模。我的经验是单表数据在 10 万条以内时不要过度索引否则写入性能会受影响。DeskcommCRM 一开始只建了三个复合索引idx_interactions_customer_time ON interactions(customer_id, occurred_at DESC)idx_tasks_due_status ON tasks(due_date, status)idx_contacts_customer ON contacts(customer_id)这套索引设计让“打开客户详情页”的操作稳定在 100ms 以内完全满足桌面应用的主观流畅感。另外SQLite 对LIKE %keyword%这种模糊搜索不会走索引但数据量不大的情况下影响有限全文搜索等高级能力我没有在第一版实现。4. 通信模块把邮件和会话收拢到客户时间线里4.1 邮件收发链路SMTP IMAP 并用通信模块是 DeskcommCRM 最贴近“Comm”定位的部分。我的设计思路是用户先配置一个邮箱账号系统通过 IMAP 拉取邮件通过 SMTP 发送邮件然后将每封邮件自动关联到对应的客户和联系人。关键是自动匹配逻辑根据发件人地址到contacts表里查找查不到就先归入“未匹配邮件”箱由用户手动确认一次确认后的规则会记录到一张映射表里下次来自同一地址的邮件就能自动归档。邮件处理流程大致如下用户点击“收取邮件”主进程启动 IMAP 连接只拉取最近 N 天的未读邮件和最近一个月的新邮件。对每封邮件解析 From 地址、正文文本内容、附件信息。地址匹配到联系人后将该封邮件插入interactions表type 设为 emailsummary 取邮件标题content 存纯文本正文。若匹配失败将邮件暂存在unmatched_mails表等待用户指定联系人。发送邮件时用户在“写邮件”弹窗填写收件人、标题、正文点击发送后先写入interactions表再通过 SMTP 发出。先写库再发信这个顺序很重要万一发送失败记录仍然保留可以在状态字段里标记为 failed。邮件正文的处理有一些反直觉的坑。HTML 邮件直接存进 SQLite 会拖慢查询而且纯文本内容概念上更适合后续搜索。所以我用了html-to-text库转成纯文本对字号、颜色、链接这些样式信息做丢弃处理。对于邮件附件我选择存储到应用数据目录下的attachments文件夹数据库里只保存文件路径避免 SQLite 文件急剧膨胀。4.2 聊天记录的导入与统一展示即时聊天工具的开放程度参差不齐我没有做实时 API 对接而是提供了一个更务实的方案用户可以把微信或企业微信的聊天记录导出为文本文件DeskcommCRM 通过解析器读取后生成互动记录。解析器用正则匹配时间戳、发送人、消息内容然后插入到当前联系人的时间线里。批量导入聊天记录时我建议分两步第一步是“导入预览”解析出的消息数量、时间范围、可能归属的联系人先展示给用户确认。第二步是“确认导入”用户点击后才真正写入数据库。这个设计避免了误操作导致大量脏数据进入主表。真实使用中出现过一版自动导入把某个客户公司群的 2000 条消息全部挂到了单个联系人下面搞得时间线完全没法看。后来加了预览确认类似问题就很少发生了。4.3 通信记录的时间线聚合时间线聚合是 DeskcommCRM 界面上最核心的组件。它的实现不复杂SQL 层把某位客户相关的邮件、会议、电话记录全部查出来然后在前端按occurred_at时间排序渲染成一个瀑布流卡片列表。每张卡片上有类型图标、标题、摘要和可展开的详情。这里有个细节不同类型的记录展示侧重点不同。邮件记录摘要展示主题行和发件人电话记录展示通话对象和时长会议记录展示会议纪要和待办事项。我在interactions表里加了一个metadata_json字段用来存各类型特有的扩展属性。SQLite 支持 JSON 函数处理后前后端可以用非常直接的方式读写这些字段不需要为了扩展性创建十几张子表。5. 工作台界面开发如何做到“打开就知道下一步干什么”5.1 布局左边客户列表中间对话时间线右边客户信息DeskcommCRM 的主界面是三栏布局。左侧是客户列表支持标签筛选和关键词搜索中间是选定客户的时间线展示所有互动记录与任务右侧是客户详情面板显示联系人信息、自定义字段和待办提醒。这个布局借鉴了邮箱客户端的交互方式因为用户对“列表-Right 详情”的注意力流向已经非常熟悉几乎不需要学习成本。客户列表默认按照最近互动时间排序而不是创建时间。这意味着经常沟通的客户会排在更靠前的位置更符合实际业务中的热点聚焦。排序字段的 SQL 逻辑是关联查询取该客户最新一条interactions.occurred_at没有互动记录的客户按创建时间降序排列。5.2 “两步以内完成核心操作”的交互原则我在做界面时给自己定了一个标准新增沟通记录、新建任务、查看某客户完整信息这三个高频操作必须保证两步以内完成。这个原则直接砍掉了大量不必要的页面跳转。比如新增沟通记录我使用的是右侧信息面板内的内嵌表单而不是新建页面或大弹窗。用户在客户 A 的时间线里点“新增记录”表单直接在面板里展开填写内容后点保存记录出现在时间线顶部。整个操作不离开当前页面也没有任何模态阻塞。这种交互在移动端的“半屏弹层”很常见但在桌面管理软件里反而不多。实践下来反馈很好尤其对我自己这种“边打电话边记录”的场景特别友好。为了保持界面轻盈我没有引入太多重量级数据可视化组件。顶部只放了一个简洁的“今日待办”卡片列出今天到期的所有任务。这比任何复杂的图表都更直接因为它告诉用户的不是抽象的趋势而是今天要做的事。5.3 React 性能优化虚拟列表和按需加载客户数量一旦超过几千一次性渲染全部客户列表会出现明显卡顿。我用了tanstack/react-virtual的虚拟列表处理左侧客户列表和中间时间线这两个组件是高频渲染区。虚拟列表的核心思想是只渲染可视区域内的 DOM 节点而不是渲染所有数据。3000 个客户的列表虚拟化之后渲染节点只有 20 个左右滚动手感非常跟手。时间线的按需加载则通过“滚动到底加载更多”的方式实现。每次加载最近 50 条记录滚动到底部时再查询更早的记录。这里有个坑时间线数据排序要稳定否则滚动加载时可能出现重复或跳位。我采用occurred_at DESC, id DESC的复合排序保证时间戳相同的记录也有确定性的顺序。5.4 托盘提醒与全局快捷键桌面应用相比 Web 应用的一大优势是能和操作系统深度融合。DeskcommCRM 在系统托盘常驻了一个小图标到了任务提醒时间通过系统通知推送内容。通知里直接附带两个按钮“完成”和“查看客户”点击后应用可以选择直接跳过任务详情页面。我还在应用中注册了全局快捷键CmdOrCtrlShiftD让用户在任何界面都能快速呼出搜索框输入客户名或者关键词后回车直接跳到对应客户。这个细节看起来小但实际使用频率非常高也明显提升了工具的“桌面感”。6. 打包部署与数据安全桌面应用不能忽视的最后一公里6.1 用 electron-builder 输出跨平台安装包DeskcommCRM 的打包分发用的是 electron-builder。它支持配置多个平台的目标格式在 Windows 下生成 NSIS 安装程序在 macOS 下生成 dmg在 Linux 下生成 AppImage 和 deb 包。我的建议是不要等开发完成后再配置打包应该从第一版开始就保证产物可安装否则后面经常会遇到模块打包路径、图标资源、原生依赖编译等一堆问题。electron-builder 的配置文件核心要点如下{ appId: com.deskcomm.crm, productName: DeskcommCRM, directories: { output: release }, files: [ dist/**/*, electron/**/*, package.json ], asar: true, win: { target: nsis, icon: build/icon.ico }, nsis: { oneClick: false, allowToChangeInstallationDirectory: true }, mac: { target: dmg, category: public.app-category.business } }这里有一个很值得注意的配置asar: true。打成 ASAR 包之后应用资源会被打包到一个归档文件里这能一定程度上防止别人直接打开资源目录看到源码。但要注意ASAR 不是加密只是打包真正敏感的密钥不应该放到前端代码里。好在 DeskcommCRM 是纯本地应用没有服务端密钥需要保护数据库文件本身的加密才是关键。6.2 SQLite 数据加密与自动备份本地应用首要的安全威胁是设备丢失或硬盘被他人访问。为了解决这个问题我在 SQLite 层引入了 SQLCipher 的加密能力。SQLCipher 是 SQLite 的加密扩展整个数据库文件通过 AES-256 加密存储应用启动时用户输入主密码解锁。主密码不保存在任何配置文件中而是使用系统密钥链macOS Keychain / Windows Credential Manager进行安全存储用户只需要在第一次启动时输入之后就自动解锁兼顾安全与便利。备份策略我采用了“自动备份 手动导出”双轨制。自动备份在每次应用退出时执行将当前数据库文件复制到备份目录保留最近 10 份并自动清理旧文件手动导出则允许用户生成一个包含数据库和附件目录的压缩包格式为标准 ZIP加密方式支持 AES-256。恢复流程也做在应用内用户选择备份文件后应用会用临时数据库校验文件头确认有效后再替换当前数据库。6.3 升级与迁移桌面应用不能破坏存量数据软件迭代中我最担心的是数据库结构变更导致用户旧数据不可用。这是桌面应用和 Web 应用最大的差异之一Web 后端升级时数据库迁移是集中完成的而桌面应用的用户各自在本地运行你无法在发布新版本的同时强制所有人同步升级。我在设计迁移机制时写了一个非常保守的规则每次升级只允许执行向前兼容的迁移脚本。新加字段必须提供默认值或允许 NULL绝不重命名或删除已有字段必须删除时先新建字段并在迁移脚本里完成数据搬运。发布新版本时应用升级后的首次启动会在日志中记录迁移执行时间和结果如果某个用户迁移失败会弹窗提示并保留原数据库副本绝不自动覆盖。这套规则虽然让开发时多写了不少兼容代码但对于桌面应用的口碑维护极其重要。用户可以不更新版本但绝对不能因为更新而丢失数据。7. 踩过的坑以及我如何用三招避免项目膨胀7.1 异步写库引发的“SQLITE_BUSY”崩溃这是开发过程中遇到的最诡异的一次故障。用户反馈在快速连点“保存记录”按钮时应用偶尔会弹出一个数据库被锁定的错误严重时直接白屏。排查过程让我意识到Electron 主进程里虽然有多个异步服务在运行但 SQLite 数据库连接本身是单连接的。邮件收取服务和用户手动保存记录同时发生时两个写操作会竞争同一个数据库句柄其中一个就可能遇到锁。解决方法有两个层面首先把数据库操作全部收敛到一个专用的写入队列中所有通过 IPC 进来的写请求都进入队列按顺序执行其次在better-sqlite3连接中设置timeout: 5000让冲突发生时等待而不是立刻报错。这次故障之后我总结出一个经验桌面应用开发中数据库操作务必定点收敛不要有多个模块各写各的链接。尤其在使用嵌入式数据库时写入串行化是最稳妥的策略。7.2 模糊搜索性能在数据增长后的退化最初客户数量只有几百条时使用LIKE %name%搜索客户完全没有感觉。但数据上万之后搜索一次要等 1 秒以上体验明显变差。检查查询计划时发现 SQLite 没有走任何索引因为以通配符开头的 LIKE 是无法用普通 B-tree 索引加速的。试过几种方案之后最终选择了 SQLite 的 FTS5 扩展来建立全文索引。FTS5 在 SQLite 中默认编译支持不需要额外安装第三方库。我创建了一个虚拟表customers_fts同步索引客户名称、公司名、备注字段并在数据写入时更新索引。查询时改用 MATCH 语法上万条数据的搜索时间从秒级别降到毫秒级别。FTS 最大的好处是通过分词器支持中文和英文的混合搜索搜索“小米科技”时能同时命中名称字段和备注字段中的“小米”。这个细节在通讯场景中非常实用毕竟真实客户数据里充满各种非结构化文本。7.3 功能膨胀是个人项目的头号敌人开发到后期我不断产生“再加一个功能就好了”的冲动。比如数据分析报表、邮件模板库、团队共享日历每个功能听起来都值得做但也都意味着开发、测试和文档成本的成倍上升。个人维护的项目最怕的就是这个。我后来给自己定了一个实用法则一个功能是否值得加入需要同时满足“高频使用”“当前工具无法替代”“代码改动可控”三个条件。例如 Dashboard 统计图虽然能展示数据趋势但在个人业务场景中并不会每天看属于低频需求果断砍掉而“快速记录电话内容”因为会每天用到且改动量不大就值得做。现在回头想DeskcommCRM 之所以能从一个原型走到稳定使用的阶段靠的不是功能数量而是把核心场景做透。它在我的工作流里扮演的角色非常固定我对它的维护也非常轻松每次打开电脑它就在托盘里安静地等着点开后该有的信息都在那里。如果你也想自建一套 CRM我不建议直接复制这个项目的功能清单而是建议先盘清楚你自己日常工作里的真实障碍是找不到客户信息还是跟进不及时还是记录分散从最小闭环开始把一套流程跑通再逐步迭代。软件没有终态只有不断贴近使用习惯的版本。这也许才是自建工具最有价值的部分。
返回列表