尧图精选

桌面端CRM系统设计实践:Electron+React+SQLite的架构与实现

🕒 发布时间:2026/9/19 9:16:47 📁 来源:尧图网络
做CRM项目之前我其实在销售团队里待过一阵子。每天最痛苦的事就是看着同事在Excel里管理客户同一个客户被重复录了三次跟进记录散落在微信聊天记录和笔记本里老板要报表的时候只能熬夜手工汇总。2023年底我下定决心自己动手做一个真正能落地的客户管理系统这就是DeskcommCRM的由来。DeskcommCRM定位很明确面向中小型销售团队和自由职业者的桌面端客户关系管理工具核心解决三件事——客户信息统一沉淀、跟进过程完整留痕、销售数据可视可控。相比于市面上一堆SaaS CRM桌面端最大的优势是数据完全本地化不上云、不订阅、不担心隐私泄露。适合有轻度自建需求、重视数据安全、又不想被复杂ERP系统绑架的团队使用。这篇文章我会把整个项目的技术选型逻辑、数据库建模思路、核心模块实现、权限设计、性能优化和踩坑经历完整拆开讲每一步都给出当时为什么这么做的理由以及事后验证的结论。对于正在做桌面端管理类应用、或者想自己搭一套CRM的朋友这篇应该能帮你省下不少试错时间。1. 项目定位与功能边界DeskcommCRM到底做什么1.1 目标用户与场景分析做软件第一件事不是写代码而是想清楚给谁用。DeskcommCRM瞄准的是三类用户10到50人的销售团队、独立顾问/经纪人、以及需要客户档案管理的小微企业主。这三类用户的共同特点是——需要一套轻量、快速、不用培训就能上手的客户管理工具但不需要Salesforce那种庞大的配置体系。场景侧我调研了身边几个真实销售团队的工作流。发现他们的日常动作高度一致录入客户信息、记录跟进沟通、安排后续任务、查询历史记录、统计成单转化。其中被吐槽最多的痛点是跟了一个月的客户翻聊天记录才发现上次答应过什么。所以DeskcommCRM把跟进历史全链路可追溯作为第一优先级功能而不是先做花哨的营销自动化。1.2 功能范围与版本裁剪策略首批版本我刻意做减法。只保留五个核心模块客户档案、联系人管理、销售机会Pipeline、跟进任务、数据看板。凡是和这五件事关系不大的功能——比如工单系统、进销存、财务对账——一律不做。做减法有两个原因一是团队精力有限二是CRM这类工具功能越重用户实际使用率反而越低。具体到业务流一个销售从拿到线索开始先建客户档案再把对接人挂到联系人下然后根据意向程度创建一个销售机会比如报价中合同审批每次沟通后在客户时间线里写跟进记录同时给自己安排下一次跟进任务。老板打开数据看板就能看到每个销售手里有多少机会、预计金额多少、哪些阶段转化率偏低。1.3 桌面端与Web端的取舍这个决策我纠结了很久。最后选Electron桌面端理由有三。一是数据本地化所有客户资料存在用户自己的电脑里对企业来说没有云端泄密风险二是离线可用销售在外面跑客户时信号不好也能正常录入三是桌面应用可以访问本地文件系统批量导入导出Excel、备份数据库都方便很多。需要说明的是单机版的DeskcommCRM不打算做实时协同。多人协同意味着需要服务端、账号体系、数据同步——那是另一套复杂度。这个版本先做好单机多用户后面会讲怎么在同一台电脑上做权限隔离等后面用户量起来了再考虑加服务端同步层。2. 技术选型复盘Electron React SQLite的组合是否合理2.1 主进程与渲染进程的分工Electron应用天然分两个进程。我在设计时把主进程Main Process定位成纯后端负责窗口管理、数据库读写、文件导入导出渲染进程Renderer只干界面的事通过IPC调用主进程开放的能力。这样分层的好处是——渲染进程就算崩溃或者被注入脚本也碰不到数据库文件安全性高不少。开发时我在主进程用Node.js的better-sqlite3操作数据库渲染进程用React管理界面。所有数据库操作都通过ipcMain.handle暴露成异步方法渲染进程用window.api调用。// preload.js 暴露给渲染进程的API contextBridge.exposeInMainWorld(api, { getCustomers: (query) ipcRenderer.invoke(db:customers:list, query), createCustomer: (data) ipcRenderer.invoke(db:customers:create, data), getPipeline: () ipcRenderer.invoke(db:pipeline:get), updateStage: (id, stage) ipcRenderer.invoke(db:opportunity:updateStage, id, stage) });IPC的调用链长得像这样React组件按钮 → 调用window.api方法 → preload → IPC → 主进程handler → SQLite执行 → 返回结果。最初我还纠结过要不要用remote模块后来发现remote不仅慢还容易出各种诡异的序列化问题果断弃用全部改成显式的IPC调用。这个改动让整个项目的调试体验上了一个台阶。2.2 为什么选better-sqlite3而不是其他存储方案数据存储我对比了三个方案SQLite、NeDB、直接写JSON文件。JSON文件第一个排除数据多了以后查询性能惨不忍睹。NeDB是纯JS方案不需要原生编译但它的持久化机制对数据量和并发写入都不友好而且项目维护活跃度不高。最终选择了better-sqlite3因为我需要的是——同步API、低内存占用、成熟的事务支持。带点私心推荐一下better-sqlite3。和sqlite3包不同它是同步API意味着写代码时不需要到处async/await去等数据库回调。配合Node.js的worker_threads我把耗时的报表查询丢到子线程跑完全不影响界面流畅度。事务处理更是稳批量导入客户数据时加一个事务包裹要么全部成功要么全部回滚不会出现导一半崩溃导致脏数据。2.3 前端框架与UI组件库的选择React 18 TypeScript是起点。TypeScript的收益在CRM这类业务系统上尤其明显——客户、联系人、机会这些实体字段太多用JS写很容易出现undefined字段导致渲染崩溃TS能把大部分错误挡在编译期。UI这块我最初想过用Ant Design毕竟企业级组件库成熟但它的包体积实在太大打包后Electron应用初始加载能慢半秒。后来换成Tailwind CSS Headless UI自己封装业务组件。头像、标签、时间线这些组件都自己写。这套组合让我能精准控制桌面应用的视觉效果更像原生软件而不是嵌了个网页。2.4 打包与自动更新方案桌面应用绕不开分发问题。DeskcommCRM用electron-builder打包配置了NSIS安装包Windows和dmgmacOS。这里有个细节打包时background的Squirrel.Windows脚本能处理安装和卸载事件应用首次启动时自动创建数据库表和默认配置用户感觉不到任何初始化过程。自动更新我用的electron-updater服务器就放在自己的VPS上发布新版本时把latest.yml和安装包传上去即可。需要注意Windows的NSIS配置里必须开启allowDowngrade和differentialPackage否则更新体验会差很多。# electron-builder.yml win: target: - target: nsis arch: [x64] nsis: oneClick: true perMachine: false allowElevation: false allowToChangeInstallationDirectory: false publish: provider: generic url: https://update.example.com/deskcomm3. 数据库与领域建模客户、联系人、机会的关系设计3.1 核心表结构与字段解析数据库建模是CRM项目的灵魂这块我前前后后重构了三次才静下来。最终定稿的五张核心表如下表名关键字段设计要点customersid, name, company, industry, source, owner_id, created_at客户主体owner_id标记归属销售contactsid, customer_id, name, title, phone, email, wechat同一客户多个联系人一对多opportunitiesid, customer_id, stage, amount, probability, expected_close_date销售机会stage用枚举控制activitiesid, customer_id, contact_id, type, content, happened_at时间线记录所有跟动态tasksid, customer_id, title, due_date, status, assignee_id跟进任务逾期要醒目标记客户和联系人拆成两张表的收益在于——一个大客户下可能有五六个对接人采购决策人和技术负责人信息不能混在一起。机会表独立出来和客户一对多这样同一客户可以有多个不同金额的机会比如既在卖软件订阅又在卖实施服务。3.2 数据库字段命名与类型约定字段命名踩了不少坑。核心原则用一句话概括prefix业务前缀 字段名统一snake_case。比如客户表的customer_name但联系人表里就不该再加customer前缀而用contact_name。虽然会导致SQL写得长一点但多表JOIN时一眼能看清字段归属不会搞混。类型选择上金额字段用REAL存储虽然浮点数有精度问题但在CRM场景下金额只做展示不做计算够用日期统一存ISO字符串2024-01-01T10:00:00Z不用时间戳这样后续做周报、月报统计分析时字符串日期可以直接做GROUP BY。状态字段用TEXT CHECK约束而不是用整型枚举——方便将来扩展阶段值。3.3 自动时间戳与软删除策略每张表我都放了created_at和updated_at在写入时统一由代码层设置不用SQLite的CURRENT_TIMESTAMP因为那样每次update要写两条SQL维护成本高。我在better-sqlite3上层封装了一个dbHelper.insert(table, data)自动注入这两个时间戳字段业务代码不用管。软删除这块我选了is_deleted标志位方案。客户和联系人这个量级的表软删除不会带来性能问题。好处是能防止误删销售手滑删掉的客户可以在回收站里恢复。查询时默认WHERE is_deleted 0需要做数据清理时再一次性物理删除。3.4 幂等性与并发写控制本地SQLite虽然并发问题没有服务端数据库严重但Electron多窗口情况下还是可能同时写同一条客户记录。better-sqlite3的写操作是串行的但两个窗口同时修改客户名称时会互相覆盖。解决办法是给customers表加一个version字段每次UPDATE带上WHERE id ? AND version ?受影响行数为0时返回冲突让前端弹窗提示数据已被他人修改请刷新重试。这就是乐观锁的老套路但在小型桌面应用里非常有效。4. 核心模块实现管道拖拽、任务提醒与数据看板4.1 销售管道拖拽的实现细节销售管道Pipeline是DeskcommCRM的交互核心。界面像Trello一样分列展示列是阶段初次接触、需求确认、方案演示、报价谈判、赢单/输单卡片是销售机会。拖拽卡片从一个阶段到另一个阶段相当于更新机会的stage字段。实现时没引入dnd-kit这种重型拖拽库而是自己用HTML5 Drag and Drop API实现的。整套交互大约150行代码dragstart时把机会id存到dataTransferdragover时在目标列高亮drop时调用更新API。function handleDrop(e, targetStage) { const oppId e.dataTransfer.getData(text/plain); updateOpportunityStage(oppId, targetStage) .then(() { setStageMap(prev ({ ...prev, [targetStage]: [oppFromStore, ...prev[targetStage]] })); }); }需要注意的是端到端的更新状态同步。界面上的卡片状态不能等后端返回重查整个管道太慢。我是先把那列卡片数据从原stage数组里移除再插到新stage数组头部然后用服务器返回的时间戳做最终一致性校验。4.2 任务提醒与逾期管理跟进任务是CRM的粘性功能。每录入一条跟进记录系统建议安排下次任务任务到了时间点自动弹通知。桌面端实现通知用Electron主进程的Notification模块比网页版只能靠浏览器通知权限强很多。逾期任务的处理我更用心。查数据库时任务列表SQL会额外计算一个overdue字段SELECT *, CASE WHEN status open AND due_date datetime(now) THEN 1 ELSE 0 END AS overdue FROM tasks WHERE assignee_id ? AND status open ORDER BY overdue DESC, due_date ASC;逾期任务在界面上显示红色底纹同时在侧边栏统计今日待办 N / 逾期 M数字实时用IPC订阅主进程的数据库通知更新。这里有个实现细节SQLite支持sqlite3_update_hook可以在表数据变更时主动推一个事件到渲染进程前端就不用轮询刷新了。4.3 仪表盘与统计查询优化数据看板本身不难难的是报表查询性能。刚开始看板加载时前端同时发5个统计请求每个请求都要扫全表做聚合在大几百万条activities数据的时候页面能卡两秒。我的优化策略分两步。第一统计查询全部汇总成一条SQL一次IPC调用把所有看板数据拿全而不是分5次调用。SELECT (SELECT COUNT(*) FROM opportunities WHERE stage IN (won)) AS won_count, (SELECT COALESCE(SUM(amount), 0) FROM opportunities WHERE stage IN (won)) AS won_amount, (SELECT COUNT(*) FROM tasks WHERE status open) AS open_tasks, ...第二月维度的转化率统计用预聚合表。每天凌晨由系统自动跑一次每日快照把当天的客户数、机会数、赢单金额这些指标存进daily_stats表看板上展示的周报、月报就直接查快照表不用再扫全表。数据准确性延迟一天可以接受但响应速度提升的是数量级。4.4 客户时间线一条SQL还原完整跟进记录时间线功能是销售最依赖的模块。它能按客户维度展示所有活动添加备注、打电话、发邮件、线下拜访、发送报价……全部混在一起按时间倒序排列。实现不复杂核心是把关联了该客户的所有activities查出来带上操作人姓名、操作类型图标、详情摘要。为了让时间线看起来像真人记录我在activities表里加了meta字段存JSON比如邮件型的活动存主题收件人正文摘要拜访型的活动存地点访谈纪要。前端根据type字段渲染不同样式的卡片。这张表建了复合索引(customer_id, happened_at DESC)时间线查询就扫索引非常快。5. 数据安全与权限模型单机多用户怎么隔离5.1 角色权限的三层设计表面上看DeskcommCRM是单机软件但一台电脑可能被团队里几个人共用比如公司配的办公电脑。所以权限模型不可少。我设计了三个角色管理员、销售主管、普通销售。权限通过一个简单的RBAC策略表达存在配置文件里。权限项管理员销售主管普通销售查看所有客户是是否仅自己删除客户是是本组否导入导出数据是是否配置阶段/管道是否否查看团队报表是是本组否实现上每个核心表都有owner_id字段。查询时如果当前用户不是管理员SQL自动拼上AND owner_id ?把数据范围卡死。字段级别的权限单独维护一套策略能不能看到客户的手机号、报价金额由管理员的配置决定渲染层根据权限决定该column渲染还是隐藏。5.2 数据库加密与本地安全单机应用最怕的是数据库文件被人拷走。我用SQLCipher加密SQLite数据库文件连接时通过用户输入的密钥解密。密钥不存文件每次登录时由用户提供。这意味着忘记密码连管理员都救不了所以我在用户设置里做了密钥备份提示——导出密钥到U盘存到安全的地方。文件层面的防护也有做应用启动时检查数据库文件权限Windows上通过ICACL把文件访问权限限制到当前用户macOS/Linux下用chmod 600。这个操作能防止同电脑上的其他操作系统账户直接读数据库文件。5.3 登录认证与凭证管理单机应用登录认证容易做飘。我用了比较朴素但管用的方案用户表里存盐值和加盐哈希scrypt算法登录时比对哈希。主进程窗口打开后先显示锁屏界面验证通过后才解密数据库、加载主界面。有个小细节应用启动时的加盐哈希计算在主进程里跑会阻塞IPC几毫秒但能防止渲染进程恶意的触发大量登录尝试。另外密码尝试次数超过5次锁定10分钟防止暴力破解。本地攻击场景下这是合理的防护水平别想着能达到企业级SSO那种强度。6. 性能优化实录大数据量下的流畅体验6.1 客户列表的虚拟滚动改造当客户数量突破一万条时客户列表卡顿变得很明显——因为DOM节点太多了。解决方案是react-window的虚拟滚动只渲染可视区域内的那20条数据滚动时动态替换。import { FixedSizeList as List } from react-window; const CustomerRow ({ index, style }) ( div style{style} CustomerCard customer{customers[index]} / /div ); List height{600} itemCount{customers.length} itemSize{48} width100% {CustomerRow} /List虚拟滚动改造后一万条数据和一千条数据的渲染时间几乎没差别。但要注意过滤和排序还是在数据库层做返回给前端的数据量也要做分页不能一次性把所有客户都扔给前端再过滤否则照样卡。6.2 查询索引设计优化SQLite索引是性能优化的金矿。随着activities表慢慢涨到几十万条记录我通过EXPLAIN QUERY PLAN找出慢查询逐个加索引。最核心的几条索引CREATE INDEX idx_customers_owner ON customers(owner_id); CREATE INDEX idx_contacts_customer ON contacts(customer_id); CREATE INDEX idx_opportunities_stage ON opportunities(stage); CREATE INDEX idx_activities_customer_time ON activities(customer_id, happened_at DESC); CREATE INDEX idx_tasks_due_status ON tasks(due_date, status, assignee_id);索引不是越多越好每个索引都会拖慢写入速度。增速规则是先看慢SQL执行计划发现全表扫描再用索引消除日常维护中定期用dbstat视图观察哪些索引从未被用到。6.3 UI渲染层面的性能监控加了一个内部开发者工具面板按F1呼出显示当前渲染进程的FPS、组件重渲染耗时、所有IPC调用的响应时间。有了这些指标后才能做调优。发现的最大性能问题是状态管理导致的重复渲染。之前用Reduxconnect层级深了后任何一个action dispatch都会导致大片组件重渲染。后来换成了Zustand按store slice拆分哪个组件依赖哪个slice就只订阅那个slice重渲染范围小了很多。这个改动让列表搜索时打字不再掉帧。6.4 大数据量导入导出方案客户数据迁移是逃不掉的坎。DeskcommCRM支持Excel导入导出。导入时用xlsx库但避免直接解析大文件——一次导入5000条记录时用xlsx的sheet_to_json开流式解析走worker_threads防止UI卡死。写SQL时一定要批量插入。better-sqlite3的transaction()方法能大幅提升写入速度const insertCustomer db.prepare(INSERT INTO customers (...) VALUES (?,?,?)); const insertMany db.transaction((customers) { for (const c of customers) insertCustomer.run(c.name, c.company, c.owner_id); }); insertMany(customerArray);实测一次性导入5000条记录不用事务时约9秒用事务后约0.8秒提升10倍以上。7. 踩坑记录Electron、SQLite与状态同步的连环雷7.1 Electron主进程崩溃导致白屏开发中期遇到一个诡异的Bug应用刚启动时白屏过几秒重启又没问题。排查了很久发现是主进程里的数据库连接在一个未被catch的Promise rejection后崩溃导致getCustomers调用返回不了结果渲染进程收到null后直接渲染空白。解决之道是给所有IPC handler加一层try-catch壳统一错误处理。ipcMain.handle返回{ok: true, data}或{ok: false, error: 具体错误信息}前端所有调用都检查apiResult.ok失败时弹全局错误Toast。这个设计后来救了我很多次问题不再是白屏而是明确的XX模块加载失败数据库加密密钥错误之类的可识别提示。7.2 SQLite的WAL模式带来的文件锁问题上线后发现一个隐蔽问题在某些系统配置下多个进程同时打开数据库时会出现database is locked错误。WAL模式允许并发读但同一时刻只能有一个写事务。因为我的主进程和定时任务都各开了一个连接偶尔并发写就锁了。解决方式是应用启动时只创建一个数据库连接所有数据库操作都通过这一个连接执行。如果确实需要多线程并发请求用worker_threads 共享连接池better-sqlite3支持在worker里同步调用但要注意连接不能跨线程共享。7.3 React StrictMode在Electron里的双调用坑React 18的StrictMode在开发环境会故意让组件挂载两次来检测副作用这在Web项目里没感觉但在Electron里会发现问题——我的组件在useEffect里调用了window.api.createCustomer结果一次点击创建了两次客户。排查后发现是StrictMode的double-invoke。生产构建时StrictMode不生效所以上线没事但开发体验很糟。最后我加了幂等控制IPC handler接收到相同traceId的调用时自动去重。这倒让我练成了所有写接口都做幂等校验的习惯后来在做自动重试时直接用上了。7.4 状态悖论乐观更新怎么和数据库保持一致管道拖拽的乐观更新一开始很美好但后来发现一个问题——如果数据库更新失败界面上的卡片已经被移动到新列但刷新后又跳回旧列用户会以为丢数据了。更麻烦的是连续拖拽多个卡片时乐观状态和数据库状态的顺序会错乱。重新设计了同步策略拖拽后立即做乐观界面更新但在主进程执行写操作时加一个递增的operationId主进程完成写后返回当前时间戳和实际数据。前端收到这个回调后不是简单替换而是比对时间戳如果本地后续有其他操作就合并处理。这套机制看起来复杂但只涉及几十行代码却解决了所有一致性困惑。8. 从DeskcommCRM到产品化的几个后续方向8.1 多端同步与Web端计划单机版本跑通后用户反馈最多的是我想在家里也看到客户信息。要做同步就需要服务端和账号体系了。目前计划是保留桌面端加一个可选的服务端同步模式把数据加密后同步到自建服务器。考虑到数据安全直接加密后上传密钥不上传到服务端这样即使服务器被攻击也无法解开客户数据。同步协议用CouchDB的复制协议会是个不错的选择它天然适合离线优先的应用能处理冲突检测和双向同步。不过这个功能的开发量不小目前还在规划阶段不急于上线。8.2 AI辅助的销售洞察AI是绕不开的话题。对CRM来说最有价值的AI功能有三个方向销售机会评分、跟进建议、报告自动生成。我的计划是先做机会评分——通过历史赢单数据的特征客户行业、机会金额、跟进频率、成交周期训练一个简单的逻辑回归模型给每个新机会打一个赢单可能性分数。这个功能对销售最直接也最容易做出效果。本地跑推理用ONNX Runtime Web或Node.js插件都行模型文件压缩后大概几MB。数据都在本地也不用担心隐私。AI这块我的原则是能用传统统计模型解决的就别动辄堆大模型数据量没到那一步之前解释性和速度更重要。8.3 插件机制与开放API现在已经有几个用户来问能不能把公司的ERP对接进来。所以下一步规划是开放HTTP API和插件机制。桌面端内置一个轻量的本地HTTP服务监听127.0.0.1外部系统可以通过API读写数据。插件机制则允许用户用JavaScript写扩展脚本在导入导出、字段联动、自动标签这些场景里自定义逻辑。插件这块其实不难本质是在数据访问层留出钩子hook再给渲染进程加上动态加载脚本的能力。但这里要想清楚安全模型——插件跑在渲染进程里不能让它直接碰数据库通过受限的IPC接口才能操作数据。8.4 代码开源与社区版本规划纠结过是否开源。最后还是决定把核心框架开源——用户可以直接用源码自建、审计安全性数据库、核心逻辑、桌面端代码都能看。这样做的好处很明显信任度拉满企业用户不用担心闭源软件藏了后门。长期来看社区贡献的插件会更快地丰富生态我现在一个人维护不过来那么多功能定制需求。开源策略上核心CRM功能走MIT协议高级功能比如多端同步、AI销售洞察采用付费订阅模式。这是我目前想到的最能兼顾可持续开发与社区贡献的模式。9. 写在最后给自研管理系统的人几条实在建议走过这个项目的完整周期我最大的体会是管理类软件的核心不是功能多而是贴合真实工作流。一个销售每天真正打开用5分钟完成跟进记录、看到下一步行动提醒这套系统就算成功了。DeskcommCRM最受欢迎的功能反而是最朴素的客户时间线和逾期任务——因为这两个功能直接解决了用户每天的实际问题。给也想自研管理系统的朋友三个建议第一从手工流程的痛点出发不要从竞品功能清单出发。去问真的在用Excel管客户的销售他们最烦躁的是什么环节那个环节就是你第一个版本该做的功能。第二数据模型设计中先画出实体关系再写代码。客户-联系人-机会-活动这四张表的关系我在项目初期没想透导致后面代码层大量适配工作。绘制简单的ER图放在设计阶段的优先级绝对能减少返工。第三桌面端CRM要善用本地存储的优势但不要被单机限制住思维。即使不做实时协同也要为将来同步、备份、服务端化留好接口。数据库层做统一封装、IPC接口遵循通用风格后面加同步层时成本会低很多。DeskcommCRM目前还在持续迭代。代码已经跑在我的备用笔记本电脑上作为主力工具两个月了每天都用它记录客户、安排跟进、生成周报。那种自己亲手做的工具每天都在帮自己工作的感觉是做这个项目最大的回报。如果你也在做类似方向欢迎交流踩坑经验见上面这些段落祝你少采点雷。
上一篇/下一篇内容由系统自动关联 返回资讯列表 →