DeskcommCRM 是我业余时间维护的一个桌面端客户关系管理系统名字拆开看就是 Desk Comm CRM把日常办公桌面上的客户资料、沟通记录、销售跟进串在同一个本地工作台里。最早做它不是想造轮子而是当时团队实在太痛了——销售手里一堆客户 Excel电话、微信、邮件各聊各的老板问一个客户最近进展得翻五六个地方才能拼出完整故事。这个项目在公司内部跑了快两年版本改了很多轮把构建过程中的思路、选型考量和踩坑记录整理出来应该能帮到正在纠结“要不要自己搭 CRM”或者“想做一个桌面工具管理客户”的朋友。文章不会讲什么高深算法重点是落地从需求拆解、技术选型到数据库设计、核心功能实现再到真实部署中遇到的坑和解决办法全程照着做就能搭出一个能用的桌面 CRM。我自己就是从一条 SQL 语句开始写起的所以即使你之前没做过桌面应用也能跟上。1. DeskcommCRM 的定位先想清楚要解决什么问题1.1 团队销售场景里的三个真实痛点这个项目最早的触发点特别朴素。我们当时是一个不到二十人的销售团队客户主要来自展会、老客户转介绍和少量线上线索。表面看大家都有客户台账实际上一问数据在哪十个人有十个答案有人在个人微信收藏里存名片有人在 Excel 里记了半年的跟进记录还有人只用手机通讯录备注。更麻烦的是同一个客户可能同时被两个人联系过谁说了什么、答应客户什么完全没有统一记录。第二个痛点是跟进流程不透明。销售口中的“在跟”到底跟到哪一步了经理很难知道。我们试过用共享表格但共享表格在多人同时编辑时经常锁死而且大家嫌麻烦不愿意填试过免费的在线 CRM又担心客户名单放在第三方平台上不安心而且免费版各种功能限制连个自定义字段都要收费。第三个痛点是查询太慢。销售在电话里被客户问“上次那个报价你发我一下”他只能先挂电话去翻聊天记录、翻邮件、翻报价单。这种体验特别伤客户信任。我那时候就开始想能不能做一个工具打开一个客户详情页就能看到这个客户从第一次沟通到现在的完整时间线——电话聊了什么、微信发了什么、邮件报了多少钱、有没有寄过样品。这就是 DeskcommCRM 最初的产品雏形。1.2 为什么不做纯 Web 版而是桌面优先在方案选型阶段团队内部讨论过好几次。最稳妥的方案听起来是做一个内部 Web 系统部署在一台服务器上大家用浏览器访问。但实际评估下来有几个问题很难绕过去。我们公司没有专职运维Linux 服务器出了问题没人会修。很多销售在外出差网络不稳定Web 系统一断网就抓瞎。客户数据是公司核心资产老板明确要求不能放到公网 SaaS 上内网服务器也需要专门维护。销售日常操作大量集中在“记录”和“查询”这两个动作上桌面应用启动快、体验稳比每次打开浏览器输入地址更符合习惯。Desktop 优先并不是说桌面比 Web 高级而是“轻量团队 数据敏感 低运维成本”这个场景里桌面本地应用更合适。我当时的判断是先用 Electron 包一个本地应用数据全部落在本机 SQLite然后通过一个简单的导入导出机制做汇总和备份。后续真有需要再在本地应用基础上加一个可选的局域网同步服务但不作为第一版的核心。这个取舍后来被证明是对的。第一版上线时大家最担心的不是功能少而是“数据在不在我电脑上”——本地文件让销售对数据有很强的掌控感这是上云方案很难给到的心理安全感。2. 技术选型与数据模型把基础打扎实2.1 技术栈Electron、Vue 3、SQLite技术栈的选择我考虑过很多组合。先说结论主进程用 Electron渲染进程用 Vue 3 Element Plus本地数据库用 SQLite 的 better-sqlite3 驱动打包用 electron-builder。Electron 被很多人吐槽体积大、内存占用高但对我们这种内部工具来说它的成熟生态和调试便利性太重要了。开发人员熟悉前端改 UI、查样式非常快而且 Electron 的桌面能力文件读写、系统托盘、通知开箱即用省去很多造轮子的时间。数据库选 better-sqlite3 是因为它是同步 API不需要处理异步回调加上 SQLite 本身是单文件数据库部署就是拷贝一个文件备份也特别简单。有人可能会问为什么不用 Tauri 替代 ElectronTauri 确实更轻量但当时 Tauri 的生态还没有现在这么大团队里也没人熟悉 Rust遇到问题容易卡壳。做内部工具关键在于快点跑起来而不是追求技术领先。2.2 核心表结构设计DeskcommCRM 的数据模型前后改过三版最后稳定下来的核心表有六张客户表customers、联系人表contacts、沟通记录表comm_logs、商机表deals、任务表tasks和标签表tags。再加上关联表和系统表总共十二张。客户表的字段设计是最纠结的。最初我照搬了标准 CRM 的复杂模型分客户、联系人、地址、来源一长串结果销售录入时嫌字段太多。后来我做了减法客户表只保留公司名、客户类型、来源、行业、状态、负责人、下次跟进时间、备注这些核心字段其他信息都扔进自定义字段 JSON 里。自定义字段用 JSON 存避免了频繁改表结构的问题。CREATE TABLE customers ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, type TEXT DEFAULT 企业, source TEXT DEFAULT , industry TEXT DEFAULT , status TEXT DEFAULT 潜在, owner TEXT DEFAULT , next_follow_time TEXT DEFAULT , remark TEXT DEFAULT , custom_fields TEXT DEFAULT {}, created_at TEXT DEFAULT (datetime(now, localtime)), updated_at TEXT DEFAULT (datetime(now, localtime)), deleted_at TEXT );把这表 SQL 写出来不是为了展示语法而是要说明几个关键设计决策。首先是软删除客户数据删了可能后悔所以我不直接 DELETE而是置 deleted_at列表默认过滤。其次是 updated_at每次更新客户信息都要刷新这个字段后面做同步和排序都靠它。再次是 custom_fields 用 JSON虽然查询时不能直接按自定义字段过滤但换来的是极高的灵活度——销售想加“来访日期”“行业规模”这种字段不用改代码。沟通记录表是 DeskcommCRM 的灵魂。每条记录包含沟通方式电话、微信、邮件、面谈、短信、方向呼出/呼入、标题、内容、关联客户 ID、关联商机 ID、操作人、时间。这里的难点是“内容”的存储微信聊天记录可能需要粘贴大段文字所以我直接存 TEXT 字段不做长度限制。CREATE TABLE comm_logs ( id INTEGER PRIMARY KEY AUTOINCREMENT, customer_id INTEGER NOT NULL, deal_id INTEGER, type TEXT DEFAULT 电话, direction TEXT DEFAULT 呼出, title TEXT DEFAULT , content TEXT DEFAULT , operator TEXT DEFAULT , happened_at TEXT DEFAULT (datetime(now, localtime)), created_at TEXT DEFAULT (datetime(now, localtime)) ); CREATE INDEX idx_comm_logs_customer ON comm_logs(customer_id, happened_at DESC);我特别强调这个索引因为客户详情页加载的就是“某客户按时间倒序的沟通记录”没有这个索引数据量一上去页面就会卡。用户体感上的“流畅”往往就是这种小细节堆出来的。2.3 商机表与任务表的搭配设计商机表管的是销售漏斗。每个客户可以挂多个商机比如一个客户既买了产品 A 又可能买产品 B。商机表的字段包括金额、阶段、预计成交日期、赢单率。阶段我做成可配置的列表默认是初次沟通、需求确认、方案报价、商务谈判、赢单/输单。任务表则是给销售做跟进提醒的。每个任务关联一个客户和一种操作类型比如“今天下午给张总打电话”“周五前发报价单”。我在设计时特意让任务表直接冗余了客户名称和负责人姓名而不是通过外键去联查。这样做的原因是列表页显示时不需要大量 JOIN查询速度快很多反正在数据修改时同步维护一下冗余字段的成本很低。从这些表的关系可以看出DeskcommCRM 不是标准的“客户-联系人-订单”层层递进模型而更像一个“客户-动态”的日志系统。客户是主体所有沟通记录、商机、任务都挂在客户下面。这个设计更符合销售的使用习惯——他们打开一个客户页面首先想看到的就是这个客户的故事而不是一堆抽象的二维表。3. 核心功能实现客户时间轴、导入与局域网同步3.1 客户时间轴页面是怎么做的客户详情页是 DeskcommCRM 最常用的界面核心就是时间轴。技术实现并不复杂但要注意几个细节。首先准备一个聚合接口把沟通记录、商机阶段变化、任务完成事件都按时间排序拼在一起。由于数据分散在三张表里我用一个 SQL UNION 来实现SELECT id, comm AS event_type, title AS display_text, happened_at AS event_time FROM comm_logs WHERE customer_id ? UNION ALL SELECT id, deal AS event_type, 商机阶段变更为: || stage AS display_text, updated_at AS event_time FROM deals WHERE customer_id ? UNION ALL SELECT id, task AS event_type, 任务: || task_name AS display_text, finish_time AS event_time FROM tasks WHERE customer_id ? AND status 已完成 ORDER BY event_time DESC;这种写法简单直接因为数据量最多几千条UNION 的性能足够。前端拿到结果后按 event_type 渲染不同的图标和颜色。时间轴排序的坑在后端因为同类事件的 created_at 和事件实际发生时间 happened_at 可能不一致。比如销售补录一条昨天的电话记录如果用 created_at 排序它会排到最前面这就不对了。所以时间轴统一用 happened_at 作为排序字段系统自动创建的电话记录才用当前时间。用户录入体验也要投入精力。我一直觉得 CRM 最大的敌人是销售不愿意录数据所以界面要尽量降低录入成本。DeskcommCRM 的客户详情页右上角固定一个“快速记录”按钮点击后弹出一个轻量窗口只需要选沟通方式、填一句总结、正文可选默认时间就是当前时间点保存即可。整个操作三秒内完成这样销售才愿意用。3.2 从 CSV 导入通话记录和联系人很多销售之前用手机通话记录和个人 Excel 管理客户让它们突然全搬到电脑上录入不现实所以导入功能特别重要。DeskcommCRM 支持两类 CSV 导入一类是联系人和客户清单一类是通话记录。联系人 CSV 的标准格式是姓名、电话、公司名、备注。导入逻辑比较微妙的地方是要做合并去重判断一个联系人是否已存在的依据不是姓名重名太多而是电话号码。同一个电话号码如果已经被某个客户关联提示是“新增联系人”还是“合并到已有客户”。这里我采用的做法是表单让用户选择“匹配联系人电话则自动绑定到对应客户否则新建客户”。这个逻辑听着简单但真写起来要注意电话号码的清洗去掉空格、短横线、86 前缀还有 400 电话特殊处理。通话记录 CSV 通常直接从手机导出不同的手机格式差很多。我的处理方式是写一个映射器让用户告诉程序“哪一列是电话号码哪一列是通话时间哪一列是通话方向”然后自动转换。日期解析是最容易出错的iTunes 导出的日期格式是 yyyy/MM/dd HH:mm:ss安卓手机往往是 yyyy-MM-dd HH:mm而且会有上午/下午标记。我写了一大堆解析函数最后统一转成 SQLite 的 datetime 格式存储。这块代码不值得炫耀而是提醒做导入功能一定要先问清楚源数据长什么样不要假设所有人导出的数据格式都一样。3.3 局域网同步与备份方案第一版是纯本地单机后来销售主管提了个需求每周要汇总团队客户跟进情况。最开始我让每个人导出 JSON主管再导入汇总但这样问题很多同步不实时而且导出的 JSON 合并时容易把另一个人的修改覆盖掉。第二版我加了局域网同步服务。原理特别简单在团队内找一个常开机的电脑当“中心节点”跑一个轻量的 Node.js HTTP 服务其他电脑上的 DeskcommCRM 定期把本地改动推上去再从服务端拉取别人的改动。同步的核心是增量每张表都有一个 updated_at 字段客户端每次同步时发送“我这边在 xx 时间之后改过哪些记录”服务端合并后再把服务端更新过的记录下发。// 同步服务端伪代码 app.post(/api/sync, (req, res) { const { clientId, lastSyncTime, changes } req.body; // 1. 接收客户端增量 changes.forEach(item saveWithMerge(item)); // 2. 返回服务端增量 const serverChanges getChangesSince(lastSyncTime); res.json({ changes: serverChanges, serverTime: now() }); });这个方案能做但我不想描述得太理想因为冲突问题真的存在。比如两个人同时改了同一个客户的负责人最后以最后一次同步的时间戳为准。我们团队体量小没有遇到特别严重的冲突如果是几十人同时高频编辑肯定需要更严谨的冲突解决策略。我的经验是不要一开始就设计一个完美的分布式同步先把“合并同一客户下的新增沟通记录”这个高频场景跑通其他情况用时间戳后写覆盖再配合定期全量备份足够用了。备份是我的另一个重点。我设置了一个“备份到本机文件夹”的功能每天自动把 SQLite 文件备份一份同时保留最近 7 天的备份。另外提供了“一键导出全量 JSON”的按钮用于临时性的归档和迁移。很多小团队没有专门的数据库管理员数据备份做到“复制粘贴”这个级别才会有价值。4. 实际开发中遇到的坑与排查技巧4.1 Electron 打包后数据库路径找不到本地数据库文件路径的问题几乎每个 Electron 新手都会遇到。开发环境下把 SQLite 文件放在项目根目录没问题但打包后用户安装到 Program Files 或者 /Applications 下程序就没有写权限了路径也不对。解决办法是使用 Electron 的 app.getPath(userData) 获取用户数据目录数据库文件统一放在这个目录下。第一版时我把数据文件和程序安装目录放一起结果客户安装后每次启动都要管理员权限特别糟糕。后来改成用户数据目录升级程序也不影响数据文件才解决。const path require(path); const { app } require(electron); const dbPath path.join(app.getPath(userData), deskcomm.db);一个实际经验是数据库文件路径要提供“打开数据目录”的按钮。用户有时候找不到数据文件就不会备份把这个入口放在设置页里点击后直接打开系统文件管理器对非技术用户特别友好。不要小看这个功能它挽救了很多次“我数据去哪了”的求助。4.2 SQLite 多进程写入冲突开发时我用的是 better-sqlite3 的同步 API单进程完全没问题。但打包后有一次用户反馈“程序卡死”排查发现是有两个窗口实例同时打开了同一个数据库文件。Electron 默认每打开一次应用就是一个新的进程如果用户重复点击启动图标就可能出现两个实例同时访问同一个 SQLite 文件。解决方法是两个层面的。首先是加单实例锁利用 Electron 的 requestSingleInstanceLock 让第二个实例直接退出并把参数转给第一个实例。其次是 SQLite 层面打开数据库时启用 WAL 模式这样读写可以并发不会出现“database is locked”的错误提示。WAL 模式对桌面应用几乎是必备的我也强烈建议所有用 SQLite 做本地库的项目都开启。const db new Database(dbPath); db.pragma(journal_mode WAL); db.pragma(synchronous NORMAL);开启 WAL 之后的副作用是数据库目录下会出现 -wal 和 -shm 文件备份时不能只拷主文件。这个问题我在备份模块里做了处理备份前先执行 checkpoint 把 WAL 内容合并回主数据库再复制主文件保证备份文件是完全一致的。4.3 中文全文搜索查不到DeskcommCRM 有一个全局搜索框要能搜客户名、联系人、沟通记录正文。我一开始想用 SQLite 的 FTS5 模块做全文索引但测试发现在默认配置下中文分词效果很差“张经理”搜索“经理”可能搜不到因为 FTS5 默认是按空格和标点断词的中文没有空格。后来我换了思路小数据量场景下不需要真正的全文索引用 LIKE 模糊查询就够用。虽然 LIKE 不走索引但一个客户表几千条记录查询时间和全文索引差异不大换来的是中文搜索稳定可靠。搜索逻辑是客户名 LIKE、联系人电话 LIKE、沟通记录内容 LIKE三者取并集按更新时间倒序。实测速度在本地 SSD 上完全够用一两毫秒就出结果。如果未来数据量真的大了我的计划是用一个轻量的外部搜索引擎插件或者先预处理中文拼音索引。但现在这套方案对小微团队就是最优解——没必要为了技术上的美观去牺牲中文场景的可靠性。4.4 时间轴记录顺序错乱与重复数据客户时间轴出现排序错乱大多是我前面提到的 happened_at 和 created_at 混用。后来统一排序字段后解决了。重复数据则是导入导致的同一批联系人文件被导入了两次又没有做匹配逻辑就会出现完全相同的两条客户记录。解决办法是在导入逻辑里增加“重复检测”导入前先检查电话号码是否与现有记录重复重复时提供三个选项跳过、覆盖、新增。默认是“跳过”这样用户误操作后不会产生脏数据。还有一次问题是同一客户被两个销售分别建了档案一个叫“北京华信科技”一个叫“华信科技北京”这种智能合并需要文本相似度计算我暂时没做只是支持手动合并——在客户列表里勾选两行右键选择“合并到新客户”系统会保留所有沟通记录这个功能也很实用。4.5 局域网同步时主键冲突同步功能上线前我在本地测试没问题但两台电脑同时各自新建了客户同步后发现两边都有 id101数据乱套了。这是因为本地 SQLite 的自增主键在两台机器上互相冲突。我的解决方法是给每张同步表增加一个全局唯一 IDUUID主键仍然保留本地自增但同步的匹配键改成 UUID。每次新增记录时生成一个 UUID不管从哪台电脑同步过来只要 UUID 相同就是同一条记录。同步时新增和修改都靠 UUID 判断而不是自增 ID。这个改动看起来很基础但如果不提前设计后面重构特别痛苦。同步模块还要注意一个细节不要同步 deleted_at 为空的记录。因为软删除的逻辑下删除其实是更新 deleted_at 字段如果只同步 deleted_at 不为空的数据可能把一个已经删除的客户再次同步到其他电脑上。所以同步时把整个记录发过去接收端看到 deleted_at 有值就执行删除逻辑这样删除动作才能被传递。5. 常见问题速查表与实用建议5.1 问题排查速查表表现可能原因排查方法解决方案双击应用没反应单实例锁未生效或者锁文件残留查看任务管理器是否有多个进程调用 requestSingleInstanceLock异常退出时清理锁文件启动提示 database is locked多进程同时写库检查是否开了多个窗口实例开启 WAL 模式、启用单实例锁、写操作加队列搜索中文客户名搜不到FTS5 分词不适合中文用 LIKE 方式测试小数据量改用 LIKE 模糊查询上传的 CSV 导入后乱码CSV 编码不是 UTF-8用文本编辑器打开检查导入时自动检测编码并转码同步后记录重复主键冲突或者没有 UUID查看数据库重复记录同步表增加 UUID 全局唯一标识打包后数据文件消失安装路径无写权限查看应用日志路径数据库放 userData 目录时间轴顺序不对happened_at 与 created_at 混用打印 SQL 排序字段统一排序字段为 happened_at这张表是我在项目维护阶段总结出来的绝大部分问题都能在里面找到对应答案。用户报问题时我通常先让用户导出日志文件然后对照表格快速定位省了非常多的沟通成本。5.2 给想自己写工具的几点建议根据做 DeskcommCRM 的这几个月经验有几点值得分享。第一个是不要贪大。我第一版只做了客户管理、沟通记录、任务提醒三类功能其他像报表、工作流、权限角色全部砍掉。功能少一点开发周期短很多也更容易让用户沉淀使用习惯。后面再加功能时用户会主动提出他们真正需要什么比你一开始拍脑袋想的需求靠谱一百倍。第二个是数据安全要提前想。刚开始我只想着功能直到有一次一个同事误删了客户档案才意识到回收站和备份有多重要。现在系统里有三重保护误删的客户可以在回收站恢复数据每天自动备份重要操作有操作日志。虽然这些功能写起来不复杂但没有它们一次失误就够让整个项目失去信任。第三个是要重视“冷启动”。一个团队开始用一个新工具最怕的是数据搬迁不进去。所以导入功能一定要做扎实要支持常见格式的客户表、联系人表、通话记录。只要能让人快速把现有数据搬进去这个工具就容易活下去如果还要手动一条条录入基本就废了。最后我觉得这类内部工具最难的不是代码而是能不能让用户养成记录的习惯。DeskcommCRM 里所有交互都围绕“快速记录”和“快速查找”设计就是希望用户每次打完电话、发完消息顺手就能花几秒钟把关键信息留在系统里。当数据积累到一定程度销售自己能看到时间线带来的价值用户粘性自然就有了。这也是我从这个项目里学到的最有价值的东西工具的功能边界其实是需求和习惯共同决定的代码只是一半另一半在于你有多了解使用它的人。