
写这个项目复盘之前先交代个背景我从去年开始接手公司内部一套叫 DeskcommCRM 的桌面端客户管理系统早期它的定位很尴尬——既不像传统 SaaS 那样打开浏览器就能用也没有移动端那种随时随地记录的优势团队里只有销售和客服零零散散在用数据越录越乱。但后来我重新梳理了这套系统的定位和核心使用场景把主流程打磨顺之后它几乎成了我们团队每天开早会必开的一个工具。这篇文章就围绕 DeskcommCRM 的整套实现思路、关键功能细节、以及我在实操过程中踩过的坑展开希望能给正在自研内部 CRM 工具或准备做桌面端业务系统的朋友一些参考。1. 项目定位与整体设计思路1.1 为什么桌面端 CRM 依然有不可替代的位置很多人一听到 CRM第一反应就是各种云产品Salesforce、HubSpot、纷享销客打开网页注册一个账号就能用好像完全没有必要自己折腾一套桌面端工具。我之前也是这么想的但真正审视业务需求后我改变了看法。我们团队的数据涉及大量客户沟通记录、历史报价、合同附件这些信息不只是“记录在案”更重要的是它们常常需要离线状态下快速调用。会议室网络不稳定客户现场洽谈时没法保证随时联网这个时候大理石桌面端的优势就体现出来了——数据存在本地打开即用不受带宽影响。另外一个核心点是数据边界销售和客服录入的信息很多带有敏感属性放在公有云 SaaS 上客户心里多少有点顾虑而本地化存储天然解决了这个问题。DeskcommCRM 这套系统最终被定位于“轻量级、可离线、强搜索、可扩展”的桌面客户管理工具核心目标用户是销售、客户成功、以及销售管理者。它不需要替代企业微信或者钉钉专注把“客户档案 跟进历史 任务提醒 数据导出”这几件事做到极致。1.2 技术选型背后的反复权衡技术选型是我花时间比较多的地方。一开始团队里有人提议用 Java Swing 做传统桌面端有人觉得用 Python 写个 Tkinter 工具带就行还有人建议直接做一个本地 Web 服务浏览器访问 localhost。后来我拍板用 Electron React SQLite 这套组合原因很简单团队前端技术底子好Electron 生态成熟SQLite 单文件数据库足够满足大部分 CRM 场景的存储需求而且部署极其简单——打包后一个 exe双击就能跑不用装数据库服务不需要配置环境。当然 Electron 也一直有内存占用大的争议但我实际用下来在关闭多余窗口、合理处理渲染进程之后内存可以控制在 200MB 左右对于办公电脑来说完全可接受。更关键的是Electron 让我可以用一套 React 代码同时管理主进程和渲染进程开发效率比传统桌面端高出很多后续如果想把部分数据同步到服务端也能直接复用这套代码逻辑。再来说说为什么选 SQLite 而不是直接上 MySQL 或者 PostgreSQL。我们的核心诉求是“单机可用、零运维”SQLite 恰好就是这种形态它是一个文件不需要账号密码不需要监听端口没有网络层暴露数据安全性和隔离性天然有保障。而 MySQL 这类服务端数据库尽管性能更强但对于只有十几个人同时用的内部工具来说完全是牛刀杀鸡反而增加了安装部署和排查故障的成本。后面我会单独讲 SQLite 在真实业务场景中遇到的锁问题那是桌面应用最容易翻车的地方之一。1.3 功能边界这个系统不该做什么DeskcommCRM 从立项第一天我就跟团队成员强调过一个原则不要试图做一个大而全的系统。很多人一听 CRM脑子里立刻浮现报表、BI大屏、销售漏斗、营销自动化一套逻辑塞进去最后每个模块都做不深用户也学不会。我们最初定义的边界是不碰营销自动化、不碰复杂权限体系只区分管理员和普通成员、不碰移动端实时同步。核心只解决客户信息沉淀和跟进效率的问题。后续所有功能迭代都围绕这两点展开。如果你也在做类似工具我强烈建议先想清楚“不做列表”而不是先想“做什么功能”这个决策会直接影响后续的开发节奏和用户接受度。2. 核心数据模型与基础能力实现2.1 客户档案表设计少即是多CRM 最核心的实体就是客户客户表设计直接决定系统能不能用顺手。早期我们设计的客户字段有三十多个公司名称、客户来源、行业、规模、地址、官网、联系人、职位、电话、邮箱、微信、备注……结果销售录入的时候抱怨连天大量字段不知道填什么最后都空着列表页一屏放不下搜索也麻烦。后来我重新梳理把客户表砍到核心字段加上扩展信息的 JSON 存储字段效果立竿见影。基础表我直接贴一个简化后的建表结构CREATE TABLE customers ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, contact_name TEXT, contact_phone TEXT, contact_email TEXT, source TEXT DEFAULT unknown, industry TEXT, status TEXT DEFAULT new, owner_id INTEGER, next_follow_time DATETIME, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, ext_json TEXT ); CREATE INDEX idx_customers_name ON customers(name); CREATE INDEX idx_customers_owner ON customers(owner_id); CREATE INDEX idx_customers_status ON customers(status);这里最重要的设计决策是ext_json字段。不同行业的客户差异很大有的需要记录店铺地址有的需要记录代理级别有的需要记录网站类型这些不确定字段如果都做成固定列表结构会越来越臃肿。而用一个 JSON 字段存储扩展信息查询的时候用 SQLite 内置的 JSON 函数比如json_extract就能读取灵活性和查询效率都有了。状态字段status我用了非常简单的枚举new新客户、following跟进中、negotiating商务谈判中、won赢单、lost输单、invalid无效。很多系统喜欢做特别复杂的销售阶段状态机但在我们的实践中六个状态已经能覆盖绝大多数业务。状态设计越复杂销售手动下次更新的成本就越高最后状态就失真了。2.2 跟进记录的时序数据结构客户跟进记录是 CRM 里使用频率最高的模块。每一次电话、微信、见面、邮件都要留痕而且这些记录必须按时间线展示否则无法还原客户转化全过程。我设计的表结构大概是这样的CREATE TABLE followups ( id INTEGER PRIMARY KEY AUTOINCREMENT, customer_id INTEGER NOT NULL, creator_id INTEGER, follow_type TEXT DEFAULT call, content TEXT NOT NULL, next_plan TEXT, next_follow_time DATETIME, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (customer_id) REFERENCES customers(id) ); CREATE INDEX idx_followups_customer ON followups(customer_id); CREATE INDEX idx_followups_time ON followups(created_at);这里有一个容易被忽视的点跟进记录表格里最好直接放一个next_plan和next_follow_time而不是让销售单独去“创建任务”。因为在实际业务中绝大多数跟进动作结束后都会自然地产生下一步计划如果分开操作销售嫌麻烦就不会去创建任务了。把两者整合在一起让销售在写跟进记录的同时填写下一步计划时间这样任务数据就能自动同步到“今日待办”面板大大降低了重复录入成本。从产品角度来说这个设计的本质是把客户信息从一个静态表单变成一条时间线。客户详情页最核心的展示区域就是“跟进时间线”销售打开一个客户就能看到第一次接触到现在所有的沟通轨迹这种感觉比一张字段密集的详情卡片直观得多也非常适合新接手客户的同事快速了解上下文。2.3 任务待办的提醒机制待办提醒这个功能看起来简单但实现方式会直接影响用户粘性。我采用的是“轮询 全局通知”双保险机制主进程每隔一分钟检查一次数据库中存在next_follow_time在当前时刻附近的任务如果有就通过系统通知弹窗提醒同时在应用界面的顶部常驻一个待办面板展示当天需要跟进的客户数量。这个机制的核心是规则设计。任务状态不能只有“到期”和“未到期”两种那样太粗了。我加了一个done字段每次跟进成功、下拉选客户并写记录时自动把当前未完成任务标记为已完成然后把用户填写的next_follow_time作为新任务的到期时间。这样每个客户始终最多只有一个未完成任务列表不会堆积重复待办项逻辑也清晰。如果你想把提醒做得更智能一些可以考虑加上“超期提醒”——超过约定时间两天还没跟进的客户列表里红色置顶超过五天的自动展示在周报里。这些都是后加的优化但基础任务机制不要做太复杂先把“到期提醒”跑通了再扩展。3. 实操过程从一个空窗口到主流程跑通3.1 工程初始化与项目结构组织我用的 Electron React 模板是electron-vite这个脚手架它比手动配置 Webpack 方便很多而且热更新体验好。初始化命令很简单npm create quick-start/electronlatest deskcomm-crm -- --template react-ts初始化完成后目录结构大概是这样的deskcomm-crm/ ├── src/ │ ├── main/ # 主进程代码 │ │ └── index.ts │ ├── preload/ # 预加载脚本暴露安全的IPC桥接 │ │ └── index.ts │ └── renderer/ # React渲染进程 │ ├── src/ │ │ ├── components/ │ │ ├── pages/ │ │ ├── services/ │ │ └── App.tsx │ └── index.html这个结构最大的好处是主进程和渲染进程代码彻底分离不会互相污染。我走的另一个关键步骤是在主进程里用better-sqlite3管理数据库连接而不是在渲染进程里直接操作数据库——渲染进程的 JavaScript 环境不应该拥有直接访问文件系统的能力那会有严重安全隐患。better-sqlite3 是一个非常优秀的库它的 API 是同步的在事务中操作数据很利落也不会像异步数据库驱动那样出现回调地狱。初始化时只需要几行代码import Database from better-sqlite3; const db new Database(customer.db); db.pragma(journal_mode WAL); db.pragma(foreign_keys ON);journal_mode WAL这一行非常关键后面我会专门讲为什么。3.2 桌面端嵌入本地化客户数据库数据存储放在本地最大的问题是数据库文件路径怎么确定。开发环境可以直接放在项目目录但打包后放置的位置就不一样了——Windows 上不能假设用户对安装目录有写权限很多企业电脑装了UAC管控。稳妥的方式是用 Electron 的app.getPath(userData)目录这个目录在 Windows 上对应%APPDATA%/DeskcommCRM在 macOS 上对应~/Library/Application Support/DeskcommCRM有写权限且不会在应用升级时被清理。所以我封装了一个数据库初始化模块大致逻辑如下import { app } from electron; import path from path; const userDataPath app.getPath(userData); const dbPath path.join(userDataPath, deskcomm-crm.db); const db new Database(dbPath);这里还有个值得留意的细节在开发环境Electron 的 userData 路径可能会跟着package.json里 productName 发生变化最好在app.whenReady()之后再获取路径否则可能拿不到正确值。另外建议在数据库初始化时打印一条包含实际路径的日志防止后面排查问题时找不到数据库在哪儿。3.3 客户列表与搜索的渐进式实现客户列表是所有页面里最常被打开的它的性能直接影响用户体验。我第一版实现时直接在 React 组件里把所有客户查出来然后前端做过滤const allCustomers db.prepare(SELECT * FROM customers).all();数据量在几千条时确实没问题但等数据库增长到几万条以后首屏加载会出现明显卡顿。后来我把分页改成了 SQL 层的延迟加载每次只加载 50 条滚动到底部再加载下一页配合一个简单的前端防抖搜索框查询性能立刻提升了一个量级。搜索框的核心查询我用了 SQLite 的LIKE模糊匹配SELECT * FROM customers WHERE name LIKE ? OR contact_name LIKE ? OR contact_phone LIKE ? ORDER BY updated_at DESC LIMIT 50 OFFSET ?;模糊搜索有个常见坑如果用户在搜索框里输入了%或_它会被当成通配符导致结果异常。我封装了一个转义函数把这些特殊字符替换掉function escapeLike(input: string) { return input.replace(/[%_\\]/g, \\$); }这个细节虽然小但确实是真实业务里会遇到的问题。搜索框还加了拼音搜索支持用的是一个叫pinyin-match的小库客户名输入“huawei”能搜出“华为”这个加分项做起来成本不高强烈推荐加进去。3.4 跟进历史时间线的渲染要点客户详情页的跟进历史时间线我最初用的是简单的纵向列表。后来发现一个问题不同跟进记录穿插着“电话、微信、见面、邮件”等不同类型单靠文本内容很难一眼区分。我于是引入了类型标签和颜色区分每个时间节点左侧做一个圆点颜色随类型变化电话是绿色、微信是蓝色、见面是紫色、邮件是灰色。时间线的渲染本身不复杂核心在于数据聚合。一个客户可能关联几十条跟进记录按created_at倒序排列再按日期分组展示。React 里我写了一个groupByDate函数function groupByDate(records: FollowUp[]) { const map new Mapstring, FollowUp[](); for (const record of records) { const date record.created_at.slice(0, 10); if (!map.has(date)) map.set(date, []); map.get(date)!.push(record); } return Array.from(map.entries()); }这个分组结果可以渲染成“今天 / 昨天 / 2025-03-12”这样的头部用户浏览时的纵向扫描成本大大降低。另外时间线区域我做了虚拟滚动否则一个客户跟进过几百条后DOM 节点太多也会卡的。虚拟滚动我直接用了react-window改造起来差不多半小时收益却很持久。3.5 导入导出与数据备份机制CRM 系统里数据是命根子所以我把导出和备份当成了基础功能来设计。在“设置”页面里提供了两个核心操作导出全部客户为 CSV 文件、导出跟进记录为 Excel 格式。CSV 导出实现简单一行数据拼接逗号分隔注意处理好中文乱码问题。Excel 我用exceljs这个库支持样式控制导出的表格可以直接拿去做周报。数据备份我采用了两种手段第一种是每次启动应用时自动备份一次把数据库文件复制成一个带时间戳的备份文件保留最近 30 份第二种是手动在设置页点击“立即备份”备份完成弹窗提醒路径。备份逻辑写起来不多几十行代码但对用户的心理安全感提升非常大。销售团队知道“数据不会丢”之后录入信息的意愿明显变高了。关于导入我支持了标准的 CSV 模板上传功能销售可以从旧系统或者 Excel 客户表里批量导入客户。导入的列映射我做了向导式界面比如第一列是“公司名称”、第二列是“联系人”用户自己下拉选择映射关系这样就不会出现列对不上的问题。导入过程中每一行校验失败的错误消息都会收集起来最后统一展示给用户方便他们回去改 Excel 再导入。4. 常见问题与亲自踩过的坑4.1 SQLite 数据库锁导致界面卡顿这是桌面端用 SQLite 最典型的问题。better-sqlite3 是同步读写数据库这意味着如果某次操作耗时长渲染进程发来的 IPC 请求会被阻塞整个界面就卡住了。具体表现是用户同时打开客户列表、点击详情、再保存一条跟进记录偶尔会发现界面卡顿好几秒。排查之后发现罪魁祸首是一次很耗时的全表查询加上没有开启 WAL 模式导致写锁互斥。解决分两步开启journal_mode WALWALWrite-Ahead Logging让读写操作不再互相阻塞读操作可以读到之前旧版本的数据而写操作追加到日志文件不会锁全表。把耗时查询放到子进程或独立的 worker 线程中执行。Electron 主进程里可以用worker_threads或者把 IPC 改成异步模式让渲染进程不等待数据库返回而是通过回调拿到结果。对大多数场景第一步就已经解决 80% 的问题了。如果查询还是很慢再去考虑字段索引、分页、甚至预聚合表。4.2 多窗口数据不同步一个关于状态不同步的案例当 DeskcommCRM 支持同时打开多个客户详情窗口时会遇到一个棘手的同步问题在窗口 A 修改了客户状态为“赢单”窗口 B 还显示着原来的“跟进中”如果用户没注意到就继续编辑保存时就会把旧数据覆盖回去。我最初的解决方案是在每个窗口的 React 组件里监听focus事件窗口重新获得焦点时重新从数据库读取最新数据。这个方法最简单、直接但有个问题——如果用户两个窗口并排显示始终没有切换焦点数据不会主动刷新。后来我引入了 Electron 主进程的广播机制任何一个窗口执行了写操作就通过webContents.send(customer-updated, { id })通知所有窗口其他窗口收到消息后如果自己正在展示同一个客户就重新加载详情数据。实现代码大致是// 主进程 function notifyCustomerUpdated(customerId: number) { for (const win of BrowserWindow.getAllWindows()) { win.webContents.send(customer-updated, customerId); } } // 渲染进程 window.api.onCustomerUpdated((id) { if (currentCustomerId id) { loadCustomerDetail(id); } });这个方案体验好很多但也要注意避免频繁刷新——如果客户名每敲一个字母就触发一次全局广播列表会因为过度刷新而闪烁。我最后的策略是编辑操作时先更新本地状态只有正式保存时才广播通知。4.3 大字段文本导致渲染性能下降跟进记录的content字段很多销售的输入习惯是长篇大论几百字甚至上千字都很常见。早期客户详情页直接渲染全部记录内容时间线一长 DOM 节点暴涨滚动流畅度明显下降。测试发现一个客户如果有 300 条跟进记录每条平均 200 字渲染出的 DOM 文本节点非常多。我优化了两个方向一是跟踪进时间线做虚拟滚动及时回收视口之外的 DOM 节点二是文本展示默认只展示前 80 个字符点击“展开全部”再加载完整内容减少首屏渲染压力。如果以后文本量更大可以考虑给followups表增加一个content_excerpt字段在写入时就把摘要提取好查询列表时只取摘要。这样查询返回的数据量也会减小前端渲染更快。4.4 IPC 通道设计中的易错点Electron 的 IPC 通信在小型项目里很容易被忽略但它才是桌面应用数据流的“主动脉”。我在开发中遇到过一个问题某个监听器被重复注册多次导致一条数据被保存了好几遍。原因是渲染进程组件每次挂载时都调用了window.api.onXxx()但组件卸载时没有对应移除监听。Electron 的ipcRenderer.on不会自动清理组件重建时监听器越积越多。解决方法是useEffect(() { const listener (event, data) handleData(data); window.api.onXxx(listener); return () { window.api.offXxx(listener); }; }, []);用offXxx注册移除函数即可。另外建议把 IPC 通道名用一个常量文件全部列出来比如src/shared/channels.ts主进程和渲染进程都从这里面引用避免手写字符串时拼错。4.5 自动更新机制与版本回滚桌面应用最麻烦的是升级。早期我的做法是直接在设置页放一个“检查更新”按钮下载新版本后提示用户手动替换文件。这种方式容易被杀毒软件拦截而且换文件过程中如果中断应用可能起不来。后来我集成了electron-updater配合私有服务器分发更新包。更新包的打包格式在 Windows 上用 NSIS 安装包在 macOS 上用 zip 加签名。发布流程变成打包新版本 - 生成升级配置文件 - 上传到服务器 - 客户端启动时自动检查更新。升级还有一个容易被忽视的问题是回滚。我后来加了开启日志每次升级前把当前版本号写到一个本地文件里如果新版本启动失败比如白屏、主进程崩溃渲染进程检测不到页面加载成功后自动触发回滚脚本把上一个版本重新拉起。这个机制虽然不常被触发但真发生一次能挽救整个团队的信心。5. 从内部工具到更完善产品的迭代方向DeskcommCRM 从原型到现在已经迭代了很多版本我最大的感受是一个内部工具最怕的不是功能少而是没人用。每加一个功能都要想清楚它能不能让使用者在 10 秒内感受到价值。比如客户搜索搜索速度和准确率就是最直接的价值跟进时间线每次打开能看到全景比翻聊天记录省时间就是价值。后续我盘了一下迭代计划觉得这几个方向值得考虑第一增加多维度的数据统计报表特别是每个销售维度的跟进量和客户转化率让管理者每周不用自己拉 SQL第二加入数据加密能力Windows 上可以用 DPAPI 对敏感字段加密macOS 对应 Keychain第三做局域网协作模式允许多人共用一个本地数据库文件的副本定期同步适合没有公网服务器的团队。当然这些方向取决于团队的实际规模和资源有时候宁可不做也不要做成半吊子。比如报表功能如果只是简单地展示几条柱状图没有结合销售流程的分析逻辑那它只是在增加维护成本。从实际运行的稳定性来说桌面端配合本地存储的工具优势是响应快、可控性强缺点是远程协作能力天生弱。如果你的团队跨地办公可以在后续版本中加一个可选的服务器同步模块但基础架构最好从一开始就不要跟云端耦合得太紧否则本地端、云端、缓存三层逻辑很容易写乱。写在最后一个值得坚持的判断我做 DeskcommCRM 最大的收获不是技术栈有多新而是明白了“工具的价值在于让一部分人的日常工作变简单”。技术选型、代码架构、异常处理这些当然都很重要但比它们更重要的是你能不能把一个业务流程先用最朴素的方式跑通再逐步打磨细节。桌面端 CRM 这个方向在过去几年被 SaaS 的光芒掩盖了不少但对特定团队来说它依然是那个最顺手、最可控的答案。各位如果想在自己的团队里做类似的系统最好先搞清楚团队真正卡在哪个环节再决定用什么架构。工具是为人服务的这条原则永远排在技术之上。